├── .eslintrc.js
├── .github
└── FUNDING.yml
├── .gitignore
├── LICENSE
├── README.md
├── app
├── components
│ ├── Accordion.jsx
│ ├── BreadcrumbWrapper.jsx
│ ├── Chart.jsx
│ ├── Connections.jsx
│ ├── DataField.jsx
│ ├── Emote.jsx
│ ├── FirstMessage.jsx
│ ├── MessageCharts.jsx
│ ├── MessageCount.jsx
│ ├── Row.jsx
│ ├── SectionLink.jsx
│ ├── Tile.jsx
│ ├── Tooltip.jsx
│ ├── TopList.jsx
│ ├── TopWordsAndEmotes.jsx
│ └── UserTile.jsx
├── entry.client.jsx
├── entry.server.jsx
├── lib
│ ├── constants.js
│ ├── extract.js
│ ├── stats
│ │ ├── analytics.js
│ │ ├── messages.js
│ │ └── stats.js
│ ├── store.js
│ └── utils.js
├── root.jsx
├── routes
│ ├── about.jsx
│ ├── index.jsx
│ ├── stats.jsx
│ └── stats
│ │ ├── channels
│ │ └── index.jsx
│ │ ├── dms
│ │ ├── $dmID.jsx
│ │ └── index.jsx
│ │ ├── index.jsx
│ │ ├── servers
│ │ ├── $serverID
│ │ │ ├── $channelID.jsx
│ │ │ └── index.jsx
│ │ └── index.jsx
│ │ └── years.jsx
└── styles
│ └── shared.css
├── jsconfig.json
├── netlify.toml
├── netlify
└── functions
│ └── server
│ └── index.js
├── package-lock.json
├── package.json
├── public
├── Whitney-Bold.woff2
├── Whitney.woff2
└── favicon.ico
└── remix.config.js
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | },
6 | extends: [
7 | 'plugin:react/recommended',
8 | 'airbnb',
9 | ],
10 | parserOptions: {
11 | ecmaFeatures: {
12 | jsx: true,
13 | },
14 | ecmaVersion: 13,
15 | sourceType: 'module',
16 | },
17 | plugins: [
18 | 'react',
19 | ],
20 | rules: {
21 | 'no-promise-executor-return': 'off',
22 | 'no-use-before-define': 'off',
23 | 'no-console': 'off',
24 | 'max-len': 'off',
25 | 'react/function-component-definition': 'off',
26 | 'react/require-default-props': 'off',
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: davidbmaier
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /.cache
3 | /netlify/functions/server/build
4 | /public/build
5 | /.netlify
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 David B. Maier
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Discord Recap
2 |
3 | A tool to explore your Discord data package - originally inspired by [Androz2091's Discord Data Package Explorer](https://ddpe.androz2091.fr/), but with a lot more details.
4 |
5 | Live version: https://discord-recap.com
6 |
7 | ## Demo
8 |
9 | 
10 | 
11 |
12 | ## What can this do?
13 |
14 | The web app scans through your Discord data package and extrapolates a ton of information about:
15 |
16 | - your account
17 | - your servers
18 | - your channels
19 | - your DMs
20 | - your messages
21 | - your activity
22 |
23 | and a bunch more!
24 |
25 | Everything happens in the browser, and no external requests are made (apart from fetching some Discord emoji).
26 |
27 | ## Setup
28 |
29 | The web app uses [Remix](https://remix.run) and [React](https://reactjs.org).
30 | To get started, simply install the dependencies (`npm i`) and run the server (`npm run dev`).
31 |
--------------------------------------------------------------------------------
/app/components/Accordion.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const Accordion = (props) => {
5 | const {
6 | header, content, onToggle, open, headerKey, onRefChange, sortable, sortOptions, onSort
7 | } = props;
8 |
9 | const accordionContent = useRef(null);
10 | const firstUpdate = useRef(true);
11 | useEffect(() => {
12 | // check for initial render so we don't scroll on load
13 | if (firstUpdate.current) {
14 | // also pass the ref upstream so its max-height can be set dynamically
15 | if (onRefChange) {
16 | onRefChange(accordionContent.current);
17 | }
18 | firstUpdate.current = false;
19 | return;
20 | }
21 |
22 | if (open) {
23 | setTimeout(() => {
24 | const toggleButton = document.getElementById(`dr-accordion-${headerKey}`);
25 | toggleButton.scrollIntoView({ behavior: 'smooth', block: 'center' });
26 | }, 300);
27 | }
28 | }, [open]);
29 |
30 | return (
31 |
68 | );
69 | };
70 |
71 | Accordion.propTypes = {
72 | header: PropTypes.node.isRequired,
73 | headerKey: PropTypes.string.isRequired,
74 | content: PropTypes.node.isRequired,
75 | sortable: PropTypes.bool,
76 | sortOptions: PropTypes.array,
77 | onSort: PropTypes.func,
78 | onToggle: PropTypes.func,
79 | open: PropTypes.bool,
80 | onRefChange: PropTypes.func,
81 | };
82 |
83 | export default Accordion;
84 |
--------------------------------------------------------------------------------
/app/components/BreadcrumbWrapper.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Link } from 'remix';
4 |
5 | const BreadcrumbWrapper = (props) => {
6 | const {
7 | breadcrumbText, breadcrumbLink, onFilter, validOptions, currentSelection,
8 | } = props;
9 |
10 | return (
11 |
11 | {
12 | connections.map((connection) => (
13 |
14 |
15 | {`${capitalizeFirstLetter(connection.type)}:`}
16 |
17 |
18 | {connection.name}
19 |
20 |
21 | ))
22 | }
23 |
24 | );
25 | };
26 |
27 | Connections.propTypes = {
28 | connections: PropTypes.arrayOf(PropTypes.shape({
29 | type: PropTypes.string.isRequired,
30 | name: PropTypes.string.isRequired,
31 | })).isRequired,
32 | };
33 |
34 | export default Connections;
35 |
--------------------------------------------------------------------------------
/app/components/DataField.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-danger -- all instances come from internal data and are needed for formatting */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | const DataField = (props) => {
6 | const {
7 | valueText, subtitle, value, icon,
8 | } = props;
9 |
10 | const isValid = () => {
11 | let invalid = false;
12 | if (Array.isArray(value)) {
13 | invalid = value.some((v) => v === '' || v === 0 || v === null || v === undefined || Number.isNaN(v) || v === false);
14 | } else {
15 | invalid = value === '' || value === 0 || value === null || value === undefined || Number.isNaN(value) || value === false;
16 | }
17 | return !invalid;
18 | };
19 |
20 | return (
21 |
83 |
84 | {`Your first message${context ? ` ${context}` : ''}:`}
85 |
86 |
87 | {formatMessage(message.content).map((messageFragment) => messageFragment)}
88 |
89 |
90 | {getReference()}
91 |
92 |
93 | );
94 | };
95 |
96 | FirstMessage.propTypes = {
97 | message: PropTypes.shape({
98 | date: PropTypes.string.isRequired,
99 | content: PropTypes.string.isRequired,
100 | channel: PropTypes.shape({
101 | name: PropTypes.string.isRequired,
102 | type: PropTypes.number.isRequired,
103 | id: PropTypes.string.isRequired,
104 | unknown: PropTypes.bool,
105 | guild: PropTypes.shape({
106 | name: PropTypes.string.isRequired,
107 | id: PropTypes.string.isRequired,
108 | }),
109 | }).isRequired,
110 | }).isRequired,
111 | context: PropTypes.string.isRequired,
112 | showChannel: PropTypes.bool,
113 | showServer: PropTypes.bool,
114 | };
115 |
116 | export default FirstMessage;
117 |
--------------------------------------------------------------------------------
/app/components/MessageCharts.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import Chart from './Chart';
5 | import { chartTypes } from '../lib/constants';
6 |
7 | const MessageCharts = (props) => {
8 | const { messageCountPerDay, messageCountPerHour, messageCountPerYear } = props;
9 |
10 | const [chartType, setChartType] = useState(chartTypes.hour);
11 |
12 | const getChartData = () => {
13 | if (chartType === chartTypes.day) {
14 | return messageCountPerDay;
15 | } if (chartType === chartTypes.hour) {
16 | return messageCountPerHour;
17 | } if (chartType === chartTypes.year) {
18 | return messageCountPerYear;
19 | }
20 | return [];
21 | };
22 |
23 | const getButtonClassNames = (type) => {
24 | let classNames = 'dr-messagecharts-button';
25 | if (type === chartType) {
26 | classNames += ' dr-messagecharts-button-active';
27 | }
28 | return classNames;
29 | };
30 |
31 | return (
32 | )/g;
166 | export const mentionRegex = /<(?:@[!&]?|#)\d+>/g;
167 |
--------------------------------------------------------------------------------
/app/lib/extract.js:
--------------------------------------------------------------------------------
1 | import { Unzip, AsyncUnzipInflate, DecodeUTF8 } from 'fflate';
2 | import { requiredFiles } from './constants';
3 |
4 | export const extractPackage = async (file) => {
5 | const uz = new Unzip();
6 | uz.register(AsyncUnzipInflate);
7 | const files = [];
8 | uz.onfile = (f) => files.push(f);
9 | if (!file.stream) {
10 | // not supported
11 | return false;
12 | }
13 | const reader = file.stream().getReader();
14 | // eslint-disable-next-line no-constant-condition -- we need to read the entire stream
15 | while (true) {
16 | // eslint-disable-next-line no-await-in-loop -- we need to await the read for the next iteration
17 | const { done, value } = await reader.read();
18 | if (done) {
19 | uz.push(new Uint8Array(0), true);
20 | break;
21 | }
22 | for (let i = 0; i < value.length; i += 65536) {
23 | uz.push(value.subarray(i, i + 65536));
24 | }
25 | }
26 |
27 | // check for required files
28 | let requiredFilesMissing = [];
29 | requiredFiles.forEach((requiredFile) => {
30 | if (!files.some((f) => f.name.match(requiredFile.value))) {
31 | requiredFilesMissing.push(requiredFile.name);
32 | }
33 | });
34 |
35 | if (requiredFilesMissing.length > 0) {
36 | throw new Error(`Package is missing required file(s): ${requiredFilesMissing.join(`, `)}`);
37 | }
38 |
39 | return files;
40 | };
41 |
42 | const getFile = (files, name) => files.find((file) => file.name === name);
43 |
44 | // eslint-disable-next-line consistent-return -- this will always return
45 | export const readFile = (files, name) => new Promise((resolve) => {
46 | const file = getFile(files, name);
47 | if (!file) return resolve(null);
48 | const fileContent = [];
49 | const decoder = new DecodeUTF8();
50 | file.ondata = (err, data, final) => {
51 | decoder.push(data, final);
52 | };
53 | decoder.ondata = (str, final) => {
54 | fileContent.push(str);
55 | if (final) resolve(fileContent.join(''));
56 | };
57 | file.start();
58 | });
59 |
--------------------------------------------------------------------------------
/app/lib/stats/analytics.js:
--------------------------------------------------------------------------------
1 | import { DecodeUTF8 } from 'fflate';
2 |
3 | import { dataEventTypes, eventTypes } from '../constants';
4 |
5 | const updateEvents = (events, event) => {
6 | const updatedEvents = { ...events };
7 | const type = event.event_type;
8 |
9 | const eventTypeKey = Object.keys(eventTypes).find((k) => eventTypes[k] === type);
10 | if (!eventTypeKey) {
11 | // check for data event types (that are not handled as counters)
12 | for (const [key, field] of Object.entries(dataEventTypes)) {
13 | if (
14 | event[field]
15 | && (!updatedEvents[key] || new Date(event.day_pt) > new Date(updatedEvents[key].day_pt))
16 | ) {
17 | // update this data event type if the field exists and day_pt is later than the currently stored one
18 | updatedEvents[key] = event;
19 | break;
20 | }
21 | }
22 |
23 | return updatedEvents;
24 | }
25 |
26 | updatedEvents[eventTypeKey] += 1;
27 | return updatedEvents;
28 | };
29 |
30 | const updateCountries = (events, event) => {
31 | const updatedEvents = { ...events };
32 | const country = event.country_code;
33 | if (!country || country === `null`) {
34 | return updatedEvents;
35 | }
36 |
37 | if (!updatedEvents.countries) {
38 | updatedEvents.countries = [];
39 | }
40 |
41 | const countryAlreadyExists = updatedEvents.countries.find((countryInStats) => countryInStats === country);
42 | if (!countryAlreadyExists) {
43 | debugger;
44 | updatedEvents.countries.push(country);
45 | }
46 | return updatedEvents;
47 | }
48 |
49 | export const collectAnalytics = (files) => new Promise((resolve) => {
50 | let file = files.find((f) => /activity\/analytics\/events-[0-9]{4}-[0-9]{5}-of-[0-9]{5}\.json/.test(f.name));
51 | if (!file) {
52 | // backup is /modeling - /activity only exists with the right privacy settings
53 | file = files.find((f) => /activity\/modeling\/events-[0-9]{4}-[0-9]{5}-of-[0-9]{5}\.json/.test(f.name));
54 | if (!file) {
55 | // last backup is /tns
56 | file = files.find((f) => /activity\/tns\/events-[0-9]{4}-[0-9]{5}-of-[0-9]{5}\.json/.test(f.name));
57 | if (!file) {
58 | resolve({});
59 | return;
60 | }
61 | }
62 | }
63 |
64 | let events = {};
65 | Object.keys(eventTypes).forEach((eventType) => {
66 | events[eventType] = 0;
67 | });
68 |
69 | const decoder = new DecodeUTF8();
70 | const startAt = Date.now();
71 | let bytesRead = 0;
72 |
73 | file.ondata = (_err, data, final) => {
74 | bytesRead += data.length;
75 | console.log(`Loading user statistics... ${Math.ceil((bytesRead / file.originalSize) * 100)}%`);
76 | const remainingBytes = file.originalSize - bytesRead;
77 | const timeToReadByte = (Date.now() - startAt) / bytesRead;
78 | const remainingTime = parseInt((remainingBytes * timeToReadByte) / 1000, 10);
79 | console.log(`Estimated time: ${remainingTime + 1}s`);
80 | decoder.push(data, final);
81 | };
82 |
83 | let previousData = '';
84 | decoder.ondata = (str, final) => {
85 | // add the previous leftover to this string
86 | const data = previousData + str;
87 | const lines = data.split('\n');
88 | lines.forEach((line) => {
89 | try {
90 | const lineData = JSON.parse(line);
91 | events = updateEvents(events, lineData);
92 | events = updateCountries(events, lineData);
93 | } catch (error) {
94 | console.debug('Unable to parse line, chunk probably ended');
95 | // save this partial line for next pass
96 | previousData = line;
97 | }
98 | });
99 |
100 | if (final) {
101 | resolve(events);
102 | }
103 | };
104 | file.start();
105 | });
106 | export default collectAnalytics;
107 |
--------------------------------------------------------------------------------
/app/lib/stats/messages.js:
--------------------------------------------------------------------------------
1 | import { parse as parseCSV } from 'papaparse';
2 |
3 | import { readFile } from '../extract';
4 | import { channelTypes } from '../constants';
5 |
6 | const translateChannelType = (type) => {
7 | if (Number.isInteger(type)) {
8 | return type;
9 | }
10 |
11 | switch (type) {
12 | case 'DM':
13 | return channelTypes.DM;
14 | case 'GROUP_DM':
15 | return channelTypes.groupDM;
16 | case 'PUBLIC_THREAD':
17 | return channelTypes.publicThread;
18 | case 'PRIVATE_THREAD':
19 | return channelTypes.privateThread;
20 | case 'GUILD_ANNOUNCEMENT':
21 | return channelTypes.newsChannel;
22 | case 'GUILD_TEXT':
23 | default:
24 | return 0;
25 | }
26 | }
27 |
28 | export const collectMessages = async (files) => {
29 | const messageIndex = JSON.parse(await readFile(files, 'messages/index.json'));
30 | const dmChannels = [];
31 | const guildChannels = [];
32 |
33 | let oldPackage = false;
34 | const file = files.find((f) => /messages\/([0-9]{16,32})\/$/.test(f.name));
35 | if (file) {
36 | oldPackage = true;
37 | console.debug('Old messages package detected');
38 | }
39 | let jsonMessages = false;
40 | const jsonFile = files.find((f) => /messages\/c([0-9]{16,32})\/messages\.json$/.test(f.name));
41 | if (jsonFile) {
42 | jsonMessages = true;
43 | console.debug('JSON messages package detected');
44 | }
45 |
46 | await Promise.all(Object.entries(messageIndex).map(async ([channelID, description]) => {
47 | const cleanUpDescription = (desc) => {
48 | if (!desc) {
49 | return 'Unknown conversation';
50 | }
51 | // remove fake discriminators
52 | if (desc.endsWith('#0000')) {
53 | return desc.substring(0, desc.length - 5);
54 | }
55 | return desc;
56 | };
57 |
58 | const channelData = {
59 | id: channelID,
60 | description: cleanUpDescription(description),
61 | };
62 |
63 | // fetch the channel metadata
64 | const channelMetadata = JSON.parse(await readFile(files, `messages/${oldPackage ? '' : 'c'}${channelData.id}/channel.json`));
65 | // old channel types were just integers, they've changed into strings in August 2024 - translate them back for internal consistency
66 | channelData.type = translateChannelType(channelMetadata.type);
67 |
68 | // fetch the channel messages
69 | if (jsonMessages) {
70 | // new JSON format for messages
71 | const rawMessages = await readFile(files, `messages/c${channelData.id}/messages.json`);
72 | if (!rawMessages) {
73 | // ignore empty channels
74 | return;
75 | }
76 | const messageData = JSON.parse(rawMessages);
77 | channelData.messages = messageData.map((message) => ({
78 | timestamp: message.Timestamp,
79 | content: message.Contents || '',
80 | attachments: message.Attachments || null,
81 | }));
82 | } else {
83 | // old CSV format for messages
84 | const rawMessages = await readFile(files, `messages/${oldPackage ? '' : 'c'}${channelData.id}/messages.csv`);
85 | if (!rawMessages) {
86 | // ignore empty channels
87 | return;
88 | }
89 | const messageData = parseCSV(rawMessages, {
90 | header: true,
91 | newline: ',\r',
92 | })
93 | .data
94 | .filter((m) => m.Timestamp)
95 | .map((m) => ({
96 | timestamp: m.Timestamp,
97 | content: m.Contents || '',
98 | attachments: m['Attachments\r'] || null, // header ends here, so the header field contains the newline char
99 | }));
100 | channelData.messages = messageData;
101 | }
102 |
103 | // fetch additional metadata and store the channels
104 | if (
105 | channelData.type === channelTypes.DM
106 | || channelData.type === channelTypes.groupDM
107 | ) {
108 | // dms
109 | channelData.name = channelMetadata.name || channelData.description || 'Unknown conversation';
110 | if (channelData.name === 'Unknown conversation') {
111 | channelData.unknown = true;
112 | }
113 | channelData.recipientIDs = channelMetadata.recipients;
114 | dmChannels.push(channelData);
115 | } else {
116 | // guild channels and unknowns
117 | channelData.name = channelMetadata.name || 'Unknown channel';
118 | if (channelData.name === 'Unknown channel') {
119 | channelData.unknown = true;
120 | }
121 | channelData.guild = channelMetadata.guild;
122 | guildChannels.push(channelData);
123 | }
124 | }));
125 |
126 | return {
127 | dmChannels,
128 | guildChannels,
129 | };
130 | };
131 | export default collectMessages;
132 |
--------------------------------------------------------------------------------
/app/lib/stats/stats.js:
--------------------------------------------------------------------------------
1 | import emojiRegex from 'emoji-regex';
2 |
3 | import { readFile } from '../extract';
4 | import { collectMessages } from './messages';
5 | import { collectAnalytics } from './analytics';
6 | import {
7 | incrementTextStats, incrementEmoteMatches, incrementWordMatches, updateFirstAndLastMessage, initializeYearStats, resolveUserTag,
8 | } from '../utils';
9 | import { clearStats, storeStats } from '../store';
10 | import {
11 | channelTypes, promotionEventTypes, technicalEventTypes, relationshipTypes, getBaseStats, emoteRegex, mentionRegex,
12 | } from '../constants';
13 |
14 | export const collectStats = async (files) => {
15 | const messages = await collectMessages(files);
16 | const analytics = await collectAnalytics(files);
17 |
18 | await collectGlobalStats(files, messages, analytics);
19 | };
20 | export default collectStats;
21 |
22 | // global information about the account (to be shown on the main stats page)
23 | const collectGlobalStats = async (files, { dmChannels, guildChannels }, analytics) => {
24 | const userData = JSON.parse(await readFile(files, 'account/user.json'));
25 | const serverData = JSON.parse(await readFile(files, 'servers/index.json'));
26 |
27 | const getConnections = () => {
28 | const connections = [];
29 | userData.connections.forEach((connection) => {
30 | connections.push({
31 | type: connection.type,
32 | name: connection.name,
33 | });
34 | });
35 |
36 | return connections;
37 | };
38 |
39 | const getPaymentsTotal = () => {
40 | const payments = [];
41 | userData.payments.forEach((payment) => {
42 | const paymentObject = ({
43 | amount: payment.amount,
44 | description: payment.description,
45 | date: new Date(payment.created_at),
46 | });
47 | payments.push(paymentObject);
48 | });
49 |
50 | return payments.map((p) => p.amount).reduce((sum, amount) => sum + amount, 0);
51 | };
52 |
53 | let darkModeEnabled = true; // assume dark mode is on
54 | let darkModeSetting = userData.settings?.settings?.appearance?.theme;
55 | if (!darkModeSetting) {
56 | darkModeSetting = userData.settings.theme;
57 | }
58 | if (darkModeSetting?.toLowerCase() === 'light') {
59 | darkModeEnabled = false;
60 | }
61 |
62 | let stats = {
63 | // account stats
64 | userID: userData.id,
65 | userTag: resolveUserTag(userData.username, userData.discriminator),
66 | darkMode: darkModeEnabled,
67 | connections: getConnections(),
68 | filesUploaded: 0,
69 | totalPaymentAmount: getPaymentsTotal(),
70 | };
71 |
72 | const messageStats = {
73 | mentionCount: 0,
74 | emoteCount: 0,
75 | emojiCount: 0,
76 | ...getBaseStats(),
77 | directMessages: {
78 | count: dmChannels.length,
79 | userCount: 0,
80 | friendCount: 0,
81 | blockedCount: 0,
82 | noteCount: 0,
83 | ...getBaseStats(),
84 | channels: [], // array of dm stats objects
85 | },
86 | serverMessages: {
87 | count: Object.entries(serverData).length,
88 | mutedCount: 0,
89 | channelCount: guildChannels.length,
90 | ...getBaseStats(),
91 | servers: [], // array of guild stats objects
92 | },
93 | yearMessages: initializeYearStats(),
94 | };
95 |
96 | // get global message stats
97 | messageStats.directMessages.userCount = dmChannels
98 | ? dmChannels.filter((channel) => channel.type === channelTypes.DM).length
99 | : 0;
100 | messageStats.directMessages.friendCount = userData.relationships
101 | ? userData.relationships.filter((relationship) => relationship.type === relationshipTypes.friend).length
102 | : 0;
103 | messageStats.directMessages.blockedCount = userData.relationships
104 | ? userData.relationships.filter((relationship) => relationship.type === relationshipTypes.blocked).length
105 | : 0;
106 | messageStats.directMessages.noteCount = Object.keys(userData.notes).length;
107 | messageStats.serverMessages.mutedCount = userData.guild_settings
108 | ? userData.guild_settings.filter((setting) => setting.muted).length
109 | : 0;
110 |
111 | const getMessageStats = (channelData, message) => {
112 | const isDM = channelData.type === channelTypes.DM || channelData.type === channelTypes.groupDM;
113 | const messageTimestamp = new Date(message.timestamp);
114 | const year = messageTimestamp.getFullYear();
115 | let words = message.content.split(/\s/g);
116 |
117 | if (words.length === 1 && words[0] === '') {
118 | // don't count empty messages towards words
119 | words = [];
120 | }
121 |
122 | // count attachments
123 | if (message.attachments) {
124 | stats.filesUploaded += message.attachments.split(' ').length;
125 | }
126 |
127 | // initialize all levels of stats objects
128 | const yearStats = messageStats.yearMessages;
129 | const dmStats = messageStats.directMessages;
130 | let dmChannelStats;
131 | const allServerStats = messageStats.serverMessages;
132 | let serverStats;
133 | let serverChannelStats;
134 |
135 | if (isDM) {
136 | // overwrite DM channel name to avoid #0 for new usernames
137 | // eslint-disable-next-line no-param-reassign
138 | channelData.name = channelData.name.endsWith('#0') ? channelData.name.substring(0, channelData.name.length - 2) : channelData.name;
139 |
140 | dmChannelStats = messageStats.directMessages.channels.find((dm) => dm.id === channelData.id);
141 | if (!dmChannelStats) {
142 | dmChannelStats = {
143 | id: channelData.id,
144 | name: channelData.name,
145 | unknown: channelData.unknown,
146 | ...getBaseStats(),
147 | };
148 | if (channelData.type === channelTypes.DM) {
149 | dmChannelStats.userID = channelData.recipientIDs.find((recipient) => recipient.id !== stats.userID);
150 | // TODO: resolve user details by ID through some API
151 | } else {
152 | dmChannelStats.userIDs = channelData.recipientIDs
153 | ? channelData.recipientIDs.filter((recipient) => recipient.id !== stats.userID)
154 | : [];
155 | }
156 |
157 | messageStats.directMessages.channels.push(dmChannelStats);
158 | }
159 | } else {
160 | serverStats = allServerStats.servers.find((server) => server.id === channelData.guild?.id);
161 | if (!serverStats) {
162 | serverStats = {
163 | id: channelData.guild?.id,
164 | name: channelData.guild?.name,
165 | channelCount: 0,
166 | ...getBaseStats(),
167 | channels: [], // array of channel stats objects
168 | };
169 | allServerStats.servers.push(serverStats);
170 | }
171 | serverChannelStats = serverStats.channels.find((channel) => channel.id === channelData.id);
172 | if (!serverChannelStats) {
173 | serverChannelStats = {
174 | id: channelData.id,
175 | name: channelData.name,
176 | unknown: channelData.unknown,
177 | serverID: channelData.guild?.id,
178 | serverName: channelData.guild?.name,
179 | ...getBaseStats(),
180 | };
181 | serverStats.channels.push(serverChannelStats);
182 | serverStats.channelCount += 1; // count channels for the server
183 | }
184 | }
185 |
186 | // increase message counts
187 | incrementTextStats(messageStats, words.length, message.content.length, messageTimestamp);
188 | if (isDM) {
189 | incrementTextStats(dmStats, words.length, message.content.length, messageTimestamp);
190 | incrementTextStats(dmChannelStats, words.length, message.content.length, messageTimestamp);
191 | } else {
192 | incrementTextStats(allServerStats, words.length, message.content.length, messageTimestamp);
193 | incrementTextStats(serverStats, words.length, message.content.length, messageTimestamp);
194 | incrementTextStats(serverChannelStats, words.length, message.content.length, messageTimestamp);
195 | }
196 | incrementTextStats(yearStats[year], words.length, message.content.length, messageTimestamp);
197 |
198 | // collect mentions (only global)
199 | const mentionMatches = message.content.match(mentionRegex);
200 | mentionMatches?.forEach(() => {
201 | messageStats.mentionCount += 1;
202 | });
203 |
204 | // process emote statistics
205 | const emoteMatches = message.content.matchAll(emoteRegex);
206 | // eslint-disable-next-line no-restricted-syntax -- matchAll returns an iterator
207 | for (const emoteMatch of emoteMatches) {
208 | // ignore spotify matches since playalongs can confuse the regex
209 | if (!emoteMatch.input.startsWith('spotify')) {
210 | messageStats.emoteCount += 1;
211 | const emoteName = emoteMatch[2];
212 | const emoteID = emoteMatch[4];
213 |
214 | incrementEmoteMatches(messageStats, emoteName, emoteID);
215 | incrementEmoteMatches(yearStats[year], emoteName, emoteID);
216 | if (isDM) {
217 | incrementEmoteMatches(dmStats, emoteName, emoteID);
218 | incrementEmoteMatches(dmChannelStats, emoteName, emoteID);
219 | } else {
220 | incrementEmoteMatches(allServerStats, emoteName, emoteID);
221 | incrementEmoteMatches(serverStats, emoteName, emoteID);
222 | incrementEmoteMatches(serverChannelStats, emoteName, emoteID);
223 | }
224 | }
225 | }
226 | // process default emoji
227 | const emojiMatches = message.content.matchAll(emojiRegex());
228 | // eslint-disable-next-line no-restricted-syntax -- matchAll returns an iterator
229 | for (const emojiMatch of emojiMatches) {
230 | messageStats.emojiCount += 1;
231 | const emoji = emojiMatch[0];
232 |
233 | incrementEmoteMatches(messageStats, emoji);
234 | incrementEmoteMatches(yearStats[year], emoji);
235 | if (isDM) {
236 | incrementEmoteMatches(dmStats, emoji);
237 | incrementEmoteMatches(dmChannelStats, emoji);
238 | } else {
239 | incrementEmoteMatches(allServerStats, emoji);
240 | incrementEmoteMatches(serverStats, emoji);
241 | incrementEmoteMatches(serverChannelStats, emoji);
242 | }
243 | }
244 |
245 | // process word statistics
246 | words.forEach((word) => {
247 | if (
248 | !word.startsWith('<')
249 | && !word.startsWith('https://')
250 | && !word.startsWith('http://')
251 | && word !== ''
252 | && word !== '-'
253 | && word.length > 5
254 | && !word.match(emojiRegex())
255 | ) {
256 | incrementWordMatches(messageStats, word);
257 | incrementWordMatches(yearStats[year], word);
258 | if (isDM) {
259 | incrementWordMatches(dmStats, word);
260 | incrementWordMatches(dmChannelStats, word);
261 | } else {
262 | incrementWordMatches(allServerStats, word);
263 | incrementWordMatches(serverStats, word);
264 | incrementWordMatches(serverChannelStats, word);
265 | }
266 | }
267 | });
268 |
269 | // find first messages
270 | updateFirstAndLastMessage(messageStats, message, channelData, messageTimestamp);
271 | updateFirstAndLastMessage(yearStats[year], message, channelData, messageTimestamp);
272 | if (isDM) {
273 | updateFirstAndLastMessage(dmStats, message, channelData, messageTimestamp);
274 | updateFirstAndLastMessage(dmChannelStats, message, channelData, messageTimestamp);
275 | } else {
276 | updateFirstAndLastMessage(allServerStats, message, channelData, messageTimestamp);
277 | updateFirstAndLastMessage(serverStats, message, channelData, messageTimestamp);
278 | updateFirstAndLastMessage(serverChannelStats, message, channelData, messageTimestamp);
279 | }
280 | };
281 |
282 | const combinedChannels = dmChannels.concat(guildChannels);
283 | combinedChannels.forEach((channelData) => {
284 | channelData.messages.forEach((message) => {
285 | getMessageStats(channelData, message);
286 | });
287 | });
288 |
289 | const sortMatches = (matches) => {
290 | const sortedMatches = Object.entries(matches).sort(
291 | ([, aValue], [, bValue]) => bValue.count - aValue.count,
292 | );
293 | const cleanMatches = [];
294 | sortedMatches.forEach(([name, { count, id, originalName }]) => {
295 | const cleanedMatch = {
296 | name,
297 | count,
298 | };
299 | if (id) {
300 | cleanedMatch.id = id;
301 | }
302 | if (originalName) {
303 | cleanedMatch.name = originalName;
304 | }
305 | cleanMatches.push(cleanedMatch);
306 | });
307 | return cleanMatches;
308 | };
309 |
310 | // sort top emotes and words (and cut off the top 20)
311 | messageStats.topEmotes = sortMatches(messageStats.topEmotes).slice(0, 20);
312 | messageStats.topWords = sortMatches(messageStats.topWords).slice(0, 20);
313 | messageStats.directMessages.topEmotes = sortMatches(messageStats.directMessages.topEmotes).slice(0, 20);
314 | messageStats.directMessages.topWords = sortMatches(messageStats.directMessages.topWords).slice(0, 20);
315 | messageStats.serverMessages.topEmotes = sortMatches(messageStats.serverMessages.topEmotes).slice(0, 20);
316 | messageStats.serverMessages.topWords = sortMatches(messageStats.serverMessages.topWords).slice(0, 20);
317 |
318 | messageStats.directMessages.channels.forEach((channel) => {
319 | const updatedChannel = channel;
320 | updatedChannel.topEmotes = sortMatches(channel.topEmotes).slice(0, 20);
321 | updatedChannel.topWords = sortMatches(channel.topWords).slice(0, 20);
322 | });
323 |
324 | messageStats.serverMessages.servers.forEach((server) => {
325 | const updatedServer = server;
326 | updatedServer.topEmotes = sortMatches(server.topEmotes).slice(0, 20);
327 | updatedServer.topWords = sortMatches(server.topWords).slice(0, 20);
328 | updatedServer.channels.forEach((channel) => {
329 | const updatedChannel = channel;
330 | updatedChannel.topEmotes = sortMatches(channel.topEmotes).slice(0, 20);
331 | updatedChannel.topWords = sortMatches(channel.topWords).slice(0, 20);
332 | });
333 | });
334 |
335 | Object.entries(messageStats.yearMessages).forEach(([, yearStats]) => {
336 | const updatedYearStats = yearStats;
337 | updatedYearStats.topEmotes = sortMatches(yearStats.topEmotes).slice(0, 20);
338 | updatedYearStats.topWords = sortMatches(yearStats.topWords).slice(0, 20);
339 | });
340 |
341 | // go through servers without IDs and pull them together
342 | messageStats.serverMessages.servers.forEach((server) => {
343 | if (!server.id) {
344 | const updatedServer = server;
345 | updatedServer.name = 'Unknown/Deleted Servers';
346 | updatedServer.unknown = true;
347 | updatedServer.id = 'unknown';
348 | }
349 | });
350 |
351 | // clean up event statistics
352 | const eventStats = { ...analytics, promotionShown: 0, errorDetected: 0 };
353 | // correct reaction count
354 | eventStats.reactionAdded -= eventStats.reactionRemoved;
355 | delete eventStats.reactionRemoved;
356 | // summarize promotion and technical events
357 | const eventCountEntries = Object.entries(eventStats);
358 | eventCountEntries.forEach(([eventKey, eventCount]) => {
359 | if (promotionEventTypes[eventKey]) {
360 | eventStats.promotionShown += eventCount;
361 | delete eventStats[eventKey];
362 | }
363 | if (technicalEventTypes[eventKey]) {
364 | eventStats.errorDetected += eventCount;
365 | delete eventStats[eventKey];
366 | }
367 | });
368 |
369 | stats = { messageStats, eventStats, ...stats };
370 |
371 | await clearStats();
372 | await storeStats(stats);
373 | };
374 |
--------------------------------------------------------------------------------
/app/lib/store.js:
--------------------------------------------------------------------------------
1 | import { get, set, del } from 'idb-keyval';
2 |
3 | const statsID = 'stats';
4 |
5 | export const storeStats = async (stats) => set(statsID, JSON.stringify(stats));
6 |
7 | export const getStats = async () => {
8 | const localStorageData = localStorage.getItem(statsID);
9 | if (localStorageData) {
10 | return localStorageData;
11 | }
12 | return get(statsID);
13 | };
14 |
15 | export const clearStats = async () => {
16 | localStorage.removeItem(statsID);
17 | await del(statsID);
18 | };
19 |
20 | export const testIDB = async () => {
21 | await set('test', 'value');
22 | await del('test');
23 | };
24 |
--------------------------------------------------------------------------------
/app/lib/utils.js:
--------------------------------------------------------------------------------
1 | import { channelTypes, getBaseStats } from './constants';
2 | import { testIDB } from './store';
3 |
4 | export const incrementTextStats = (category, wordLength, characterLength, messageTimestamp) => {
5 | const updatedCategory = category;
6 | updatedCategory.messageCount += 1;
7 | updatedCategory.wordCount += wordLength;
8 | updatedCategory.characterCount += characterLength;
9 | updatedCategory.messageCountPerHour[messageTimestamp.getHours()] += 1;
10 | // move sundays to the back
11 | let day = messageTimestamp.getDay() - 1;
12 | if (day === -1) {
13 | day = 6;
14 | }
15 | updatedCategory.messageCountPerDay[day] += 1;
16 | updatedCategory.messageCountPerYear[messageTimestamp.getFullYear()] += 1;
17 | };
18 |
19 | export const incrementEmoteMatches = (category, emoteName, emoteID) => {
20 | const updatedCategory = category;
21 | const lowerCaseName = emoteName.toLowerCase();
22 | if (!updatedCategory.topEmotes[lowerCaseName]) {
23 | updatedCategory.topEmotes[lowerCaseName] = {
24 | originalName: emoteName,
25 | count: 1,
26 | id: emoteID,
27 | };
28 | } else {
29 | updatedCategory.topEmotes[lowerCaseName].count += 1;
30 | }
31 | };
32 |
33 | export const incrementWordMatches = (category, word) => {
34 | const updatedCategory = category;
35 | if (!updatedCategory.topWords[word]) {
36 | updatedCategory.topWords[word] = {
37 | count: 1,
38 | };
39 | } else {
40 | updatedCategory.topWords[word].count += 1;
41 | }
42 | };
43 |
44 | export const updateFirstAndLastMessage = (category, message, channelData, messageTimestamp) => {
45 | const unknownData = {};
46 | if (
47 | (channelData.type !== channelTypes.DM && channelData.type !== channelTypes.groupDM)
48 | && !channelData.guild
49 | ) {
50 | unknownData.guild = {
51 | id: 'unknown',
52 | name: 'Unknown/Deleted Servers',
53 | };
54 | }
55 |
56 | const updatedCategory = category;
57 | if (!updatedCategory.firstMessage || messageTimestamp < updatedCategory.firstMessage.date || updatedCategory.firstMessage?.content === ``) {
58 | updatedCategory.firstMessage = {
59 | date: messageTimestamp,
60 | content: message.content,
61 | channel: { ...channelData, messages: null, ...unknownData },
62 | };
63 | }
64 | if (!updatedCategory.lastMessage || messageTimestamp > updatedCategory.lastMessage.date || updatedCategory.lastMessage?.content === ``) {
65 | updatedCategory.lastMessage = {
66 | date: messageTimestamp,
67 | content: message.content,
68 | channel: { ...channelData, messages: null, ...unknownData },
69 | };
70 | }
71 | };
72 |
73 | export const cleanChartData = (data) => Object.entries(data).map(([category, count]) => ({ category, count }));
74 |
75 | export const usePlural = (word, value, plural) => {
76 | if (value === 1) {
77 | return word;
78 | }
79 | return `${plural || `${word}s`}`;
80 | };
81 |
82 | export const formatNumber = (number) => number?.toLocaleString('en-US');
83 |
84 | export const initializeYearStats = () => {
85 | const baseYears = {};
86 | // Discord was founded in 2015
87 | for (let i = 2015; i < new Date().getFullYear() + 1; i += 1) {
88 | baseYears[i] = {
89 | ...getBaseStats(),
90 | };
91 | }
92 | return baseYears;
93 | };
94 |
95 | export const checkForMobile = (userAgent) => /Mobi/i.test(userAgent);
96 |
97 | export const checkForFFPrivate = async () => {
98 | try {
99 | await testIDB();
100 | return false;
101 | } catch (error) {
102 | return true;
103 | }
104 | };
105 |
106 | export const resolveUserTag = (name, discriminator) => {
107 | if (discriminator.toString() === '0') {
108 | return name;
109 | }
110 | return `${name}#${discriminator.toString().padStart(4, '0')}`;
111 | };
112 |
--------------------------------------------------------------------------------
/app/root.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Links,
4 | LiveReload,
5 | Meta,
6 | Outlet,
7 | Scripts,
8 | ScrollRestoration,
9 | useCatch,
10 | } from 'remix';
11 |
12 | import styles from './styles/shared.css';
13 |
14 | // include global styles
15 | export function links() {
16 | return [{ rel: 'stylesheet', href: styles }];
17 | }
18 |
19 | export function meta() {
20 | return { title: 'Discord Recap' };
21 | }
22 |
23 | // eslint-disable-next-line react/prop-types -- ErrorBoundary always gets an error
24 | export function ErrorBoundary({ error }) {
25 | console.error(error);
26 | return (
27 |
28 |
29 | Oh no!
30 |
31 |
32 |
33 |
34 |
35 |
36 |
Uh-oh, looks like something went wrong.
37 |
42 |
43 | You can find the error in the browser console (F12 - Console).
44 |
45 |
46 |
47 |
48 |
49 |
50 | );
51 | }
52 |
53 | export function CatchBoundary() {
54 | const caught = useCatch();
55 |
56 | return (
57 |
58 |
59 |
60 | {caught.status}
61 | {' '}
62 | {caught.statusText}
63 |
64 |
65 |
66 | );
67 | }
68 |
69 | export default function App() {
70 | return (
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | {process.env.NODE_ENV === 'development' && }
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/app/routes/about.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-array-index-key -- all keys are hard-coded anyway */
2 | import React from 'react';
3 | import { Link } from 'remix';
4 |
5 | import Row from '../components/Row';
6 | import Tile from '../components/Tile';
7 |
8 | const getAboutProject = () => {
9 | const paragraphs = [
10 | 'Discord Recap was made by ',
11 | David B. Maier (@tooInfinite) ,
12 | ' - its code can be found ',
13 | here ,
14 | '.',
15 | ,
16 | 'You can support the creator by becoming ',
17 | a sponsor ,
18 | '!',
19 | ];
20 | return paragraphs.map((paragraph, index) => (
21 | {paragraph}
22 | ));
23 | };
24 |
25 | const getLegal = () => {
26 | const paragraphs = [
27 | "This project is not affiliated with Discord - it's just a community-created tool to visualize all the stats in your data package.",
28 | ];
29 | return paragraphs.map((paragraph, index) => (
30 | {paragraph}
31 | ));
32 | };
33 |
34 | const getPrivacyPolicy = () => {
35 | const paragraphs = [
36 | 'This project does not collect any personal data. All the extrapolated stats are stored in your browser\'s local storage. No external requests are made that include any personal information.',
37 | ];
38 | return paragraphs.map((paragraph, index) => (
39 | {paragraph}
40 | ));
41 | };
42 |
43 | const getAboutTheData = () => {
44 | const paragraphs = [
45 | 'If you encounter any problems, feel free to open an issue ',
46 | here ,
47 | '.',
48 | ,
49 | 'In the end, this project is just presenting the data as it finds it in your data package - there are no guarantees the data is accurate.',
50 | ];
51 | return paragraphs.map((paragraph, index) => (
52 | {paragraph}
53 | ));
54 | };
55 |
56 | export default function About() {
57 | return (
58 | <>
59 |
60 |
61 |
About Discord Recap
62 |
63 |
64 |
65 | {getAboutProject()}
66 |
67 |
68 |
69 |
70 |
71 |
72 | {getLegal()}
73 |
74 |
75 |
76 |
77 |
78 |
79 | {getPrivacyPolicy()}
80 |
81 |
82 |
83 |
84 |
85 |
86 | {getAboutTheData()}
87 |
88 |
89 |
90 |
91 |
92 |
93 | Back
94 |
95 | >
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/app/routes/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { Link } from 'remix';
3 |
4 | import { AiOutlineLoading } from 'react-icons/ai';
5 |
6 | import { extractPackage } from '../lib/extract';
7 | import { collectStats } from '../lib/stats/stats';
8 | import { checkForFFPrivate, checkForMobile } from '../lib/utils';
9 | import Row from '../components/Row';
10 | import Tile from '../components/Tile';
11 |
12 | export default function Home() {
13 | const [loading, setLoading] = useState(false);
14 | const [error, setError] = useState(null);
15 | const [isMobileDevice, setIsMobileDevice] = useState(false);
16 | const [isFFPrivate, setIsFFPrivate] = useState(false);
17 |
18 | useEffect(() => {
19 | const checkSpecialCases = async () => {
20 | setIsMobileDevice(checkForMobile(window.navigator.userAgent));
21 | setIsFFPrivate(await checkForFFPrivate());
22 | };
23 |
24 | checkSpecialCases();
25 | }, []);
26 |
27 | const handlePackage = async (file) => {
28 | try {
29 | setLoading(true);
30 | setError();
31 | const extractedPackage = await extractPackage(file);
32 | await collectStats(extractedPackage);
33 | window.location.href = '/stats';
34 | } catch (packageError) {
35 | setLoading(false);
36 | setError(packageError);
37 | console.error(packageError);
38 | }
39 | };
40 |
41 | const fileUpload = () => {
42 | const input = document.createElement('input');
43 | input.setAttribute('type', 'file');
44 | input.setAttribute('accept', '.zip');
45 | input.addEventListener('change', (e) => handlePackage(e.target.files[0]));
46 | input.click();
47 | };
48 |
49 | const handleFileDrop = (event) => {
50 | event.preventDefault();
51 | let file;
52 | if (event.dataTransfer.items) {
53 | if (event.dataTransfer.items[0].kind === 'file') {
54 | file = event.dataTransfer.items[0].getAsFile();
55 | }
56 | } else {
57 | [file] = event.dataTransfer.files;
58 | }
59 |
60 | handlePackage(file);
61 | };
62 |
63 | const handleDragOver = (event) => {
64 | event.preventDefault();
65 | event.stopPropagation();
66 | };
67 |
68 | const getParagraphs = () => {
69 | const messages = [
70 | 'Ever wondered what kind of data Discord knows about you?',
71 | 'You\'re in the right place!',
72 | 'Simply follow the instructions below to get started.',
73 | ];
74 | return messages.map((message) => (
75 | {message}
76 | ));
77 | };
78 |
79 | const getInstructions = () => {
80 | const messages = [
81 | '1. Request your data package from Discord!',
82 | 'User Settings / Data & Privacy / Request all of my data',
83 | 'Make sure to select Account, Activity, Messages and Servers.',
84 | '2. It can take a day or two - when it arrives, come back here!',
85 | '3. Get your data package analyzed!',
86 | "Very important: Nothing gets uploaded, everything happens in your browser - so you don't have to worry about your data getting stolen or misused.",
87 | ];
88 | return messages.map((message) => (
89 | {message}
90 | ));
91 | };
92 |
93 | const getMobileDisclaimer = () => {
94 | const messages = [
95 | 'It looks like you\'re using a mobile device.',
96 | 'Due to the size of Discord data packages, the performance cost of analyzing them is quite high.',
97 | 'If you want to use this site, please switch to a desktop browser.',
98 | ];
99 | return messages.map((message) => (
100 | {message}
101 | ));
102 | };
103 |
104 | const getFFPrivateDisclaimer = () => {
105 | const messages = [
106 | 'It looks like you\'re using a Firefox Private Window.',
107 | 'Due to browser restrictions, this site is unable to store its data locally in this setup.',
108 | 'If you want to use this site, please switch to a different browser or out of the Private Window.',
109 | ];
110 | return messages.map((message) => (
111 | {message}
112 | ));
113 | };
114 |
115 | return (
116 | <>
117 |
118 |
119 |
Discord Recap
120 |
121 |
122 | {getParagraphs()}
123 |
124 |
125 |
126 |
127 | {getInstructions()}
128 | {
129 | !isMobileDevice && !isFFPrivate && (
130 |
131 | {
132 | loading
133 | ? (
134 |
135 | Preparing your recap...
136 |
137 |
138 |
139 |
140 | )
141 | :
Get your data package analyzed!
142 | }
143 |
144 | )
145 | }
146 |
147 |
148 |
149 | {
150 | isMobileDevice && (
151 |
152 |
153 | {getMobileDisclaimer()}
154 |
155 |
156 | )
157 | }
158 | {
159 | isFFPrivate && (
160 |
161 |
162 | {getFFPrivateDisclaimer()}
163 |
164 |
165 | )
166 | }
167 | {
168 | error && (
169 |
170 |
171 |
172 |
Uh-oh, looks like something went wrong.
173 | {
174 | error?.message?.includes("missing required file")
175 | ? <>
176 |
177 | {error.message}
178 |
179 |
180 |
181 | If you're sure you picked the correct file, you likely didn't select all the required collections (see above) when you requested your data package from Discord.
182 |
183 | >
184 | : <>
185 |
190 |
191 | You can find the error in the browser console (F12 - Console).
192 |
193 | >
194 | }
195 |
196 |
197 |
198 | )
199 | }
200 |
201 |
202 |
203 | About
204 |
205 | >
206 |
207 | );
208 | }
209 |
--------------------------------------------------------------------------------
/app/routes/stats.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Outlet, useLocation } from 'remix';
3 | import html2canvas from 'html2canvas';
4 |
5 | import { clearStats } from '../lib/store';
6 | import { checkForMobile } from '../lib/utils';
7 |
8 | export default function StatsWrapper() {
9 | const location = useLocation();
10 | const [isMobileDevice, setIsMobileDevice] = useState(true);
11 |
12 | useEffect(() => {
13 | setIsMobileDevice(checkForMobile(window.navigator.userAgent));
14 | }, []);
15 |
16 | const resetData = async () => {
17 | await clearStats();
18 | window.location.href = '/';
19 | };
20 |
21 | const takeScreenshot = () => {
22 | html2canvas(document.getElementById('dr-share-content'), {
23 | useCORS: true,
24 | }).then((canvas) => {
25 | const downloadLink = document.createElement('a');
26 | downloadLink.setAttribute('download', 'discordStats.png');
27 | canvas.toBlob((blob) => {
28 | const url = URL.createObjectURL(blob);
29 | downloadLink.setAttribute('href', url);
30 | downloadLink.click();
31 | });
32 | });
33 | };
34 |
35 | return (
36 | <>
37 |
38 |
Discord Recap
39 |
40 |
41 |
42 |
43 |
44 |
45 | {'Made with ❤️ by '}
46 | David B. Maier
47 |
48 |
49 | resetData()}>Reset data
50 |
51 | {
52 | // only show screenshot button if we're on the stats page and not on mobile
53 | location.pathname === '/stats' && !isMobileDevice && (
54 |
55 | takeScreenshot()}>Share
56 |
57 | )
58 | }
59 |
60 |
65 | >
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/app/routes/stats/channels/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 |
3 | import TopList from '../../../components/TopList';
4 | import Row from '../../../components/Row';
5 | import Tile from '../../../components/Tile';
6 | import BreadcrumbWrapper from '../../../components/BreadcrumbWrapper';
7 | import { getStats } from '../../../lib/store';
8 |
9 | export default function Channels() {
10 | const [stats, setStats] = useState(null);
11 |
12 | useEffect(() => {
13 | getStats().then((storedStats) => {
14 | const globalStats = JSON.parse(storedStats);
15 | const channelData = globalStats.messageStats.serverMessages.servers.map((server) => {
16 | const channels = server.channels.map((channel) => ({
17 | name: `${channel.name} (${server.name})`,
18 | id: channel.id,
19 | value: `${channel.messageCount} messages`,
20 | count: channel.messageCount,
21 | link: `/stats/servers/${server.id}/${channel.id}`,
22 | unknown: channel.unknown,
23 | firstMessage: channel.firstMessage,
24 | lastMessage: channel.lastMessage
25 | }));
26 | return channels;
27 | }).flat().sort(({ count: value1 }, { count: value2 }) => value2 - value1);
28 | setStats(channelData);
29 | });
30 | }, []);
31 |
32 | return (
33 |
34 |
Your channels
35 |
39 | {stats && (
40 |
41 |
42 |
48 |
49 |
50 | )}
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/app/routes/stats/dms/$dmID.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useLoaderData } from 'remix';
3 |
4 | import { getStats } from '../../../lib/store';
5 | import { cleanChartData } from '../../../lib/utils';
6 | import Row from '../../../components/Row';
7 | import Tile from '../../../components/Tile';
8 | import FirstMessage from '../../../components/FirstMessage';
9 | import MessageCount from '../../../components/MessageCount';
10 | import MessageCharts from '../../../components/MessageCharts';
11 | import TopWordsAndEmotes from '../../../components/TopWordsAndEmotes';
12 | import BreadcrumbWrapper from '../../../components/BreadcrumbWrapper';
13 |
14 | export const loader = async ({ params }) => params.dmID;
15 |
16 | export default function DM() {
17 | const dmID = useLoaderData();
18 | const [stats, setStats] = useState(null);
19 |
20 | useEffect(() => {
21 | getStats().then((storedStats) => {
22 | const globalStats = JSON.parse(storedStats);
23 | const dmStats = globalStats.messageStats.directMessages.channels.find((channel) => channel.id === dmID);
24 | setStats(dmStats);
25 | });
26 | }, []);
27 |
28 | return (
29 |
30 | {
31 | stats && (
32 | <>
33 |
{stats.name}
34 |
38 |
39 |
40 |
48 |
49 |
50 |
54 |
55 |
56 |
57 |
58 |
63 |
64 |
65 |
66 | >
67 | )
68 | }
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/app/routes/stats/dms/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 |
3 | import { BsPerson } from 'react-icons/bs';
4 | import { AiOutlineUsergroupAdd } from 'react-icons/ai';
5 | import { MdOutlineBlock } from 'react-icons/md';
6 |
7 | import { getStats } from '../../../lib/store';
8 | import { cleanChartData, usePlural, formatNumber } from '../../../lib/utils';
9 | import TopList from '../../../components/TopList';
10 | import Row from '../../../components/Row';
11 | import Tile from '../../../components/Tile';
12 | import DataField from '../../../components/DataField';
13 | import FirstMessage from '../../../components/FirstMessage';
14 | import MessageCount from '../../../components/MessageCount';
15 | import MessageCharts from '../../../components/MessageCharts';
16 | import TopWordsAndEmotes from '../../../components/TopWordsAndEmotes';
17 | import BreadcrumbWrapper from '../../../components/BreadcrumbWrapper';
18 |
19 | export default function DMs() {
20 | const [stats, setStats] = useState(null);
21 |
22 | useEffect(() => {
23 | getStats().then((storedStats) => {
24 | const globalStats = JSON.parse(storedStats);
25 | const userStats = globalStats.messageStats.directMessages;
26 | userStats.channels = userStats.channels.map((channel) => ({
27 | name: `${channel.name}`,
28 | id: channel.id,
29 | value: `${channel.messageCount} messages`,
30 | count: channel.messageCount,
31 | link: `/stats/dms/${channel.id}`,
32 | unknown: channel.unknown,
33 | firstMessage: channel.firstMessage,
34 | lastMessage: channel.lastMessage
35 | })).sort(({ count: value1 }, { count: value2 }) => value2 - value1);
36 | setStats(userStats);
37 | });
38 | }, []);
39 |
40 | return (
41 |
42 |
Your DMs
43 |
47 | {
48 | stats && (
49 | <>
50 |
51 |
52 | ${formatNumber(stats.count)} ${usePlural('DM', stats.count, 'different DMs')}.`}
54 | subtitle={`${formatNumber(stats.userCount)} of those were individual users.`}
55 | value={stats.count}
56 | icon={ }
57 | />
58 | ${formatNumber(stats.friendCount)} ${usePlural('friend', stats.friendCount)}.`}
60 | value={stats.friendCount}
61 | icon={ }
62 | />
63 | ${formatNumber(stats.blockedCount)} ${usePlural('person', stats.friendCount, 'people')}.`}
65 | value={stats.blockedCount}
66 | icon={ }
67 | />
68 |
69 |
70 |
78 |
79 |
80 |
85 |
86 |
87 |
88 |
89 |
94 |
95 |
96 |
97 |
98 |
99 |
105 |
106 |
107 | >
108 | )
109 | }
110 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/app/routes/stats/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-len -- message fields just need extra length */
2 | import React, { useEffect, useState } from 'react';
3 | import { useOutletContext } from 'remix';
4 |
5 | import { IoNotificationsOutline, IoWarningOutline, IoGameControllerOutline } from 'react-icons/io5';
6 | import {
7 | AiOutlineEdit, AiOutlineDelete, AiOutlineExport, AiTwotoneCalendar, AiOutlineEye,
8 | } from 'react-icons/ai';
9 | import {
10 | BsEmojiSmile, BsDoorOpen, BsWindow, BsSearch, BsMegaphone, BsPerson, BsUpload,
11 | } from 'react-icons/bs';
12 | import {
13 | MdPlusOne, MdSaveAlt, MdOutlineDarkMode, MdOutlineKeyboard, MdOutlineUpdate, MdGroups,
14 | } from 'react-icons/md';
15 | import { GoMention } from 'react-icons/go';
16 | import { BiFoodMenu } from 'react-icons/bi';
17 | import { FaDollarSign, FaRegUser } from 'react-icons/fa';
18 | import { VscReport, VscError, VscGlobe } from 'react-icons/vsc';
19 | import { FiMonitor } from 'react-icons/fi';
20 | import { CgFormatSlash } from "react-icons/cg";
21 |
22 | import { getStats } from '../../lib/store';
23 | import { cleanChartData, usePlural, formatNumber } from '../../lib/utils';
24 | import Row from '../../components/Row';
25 | import Tile from '../../components/Tile';
26 | import DataField from '../../components/DataField';
27 | import MessageCount from '../../components/MessageCount';
28 | import UserTile from '../../components/UserTile';
29 | import FirstMessage from '../../components/FirstMessage';
30 | import Connections from '../../components/Connections';
31 | import TopWordsAndEmotes from '../../components/TopWordsAndEmotes';
32 | import MessageCharts from '../../components/MessageCharts';
33 | import SectionLink from '../../components/SectionLink';
34 |
35 | export default function Stats() {
36 | const { shareable } = useOutletContext();
37 | const [stats, setStats] = useState(null);
38 |
39 | useEffect(() => {
40 | getStats().then((storedStats) => {
41 | const globalStats = JSON.parse(storedStats);
42 | // remove message details since they're not needed
43 | delete globalStats.messageStats.directMessages;
44 | delete globalStats.messageStats.serverMessages;
45 | setStats(globalStats);
46 | });
47 | }, []);
48 |
49 | const getMessageDataFields = () => {
50 | let messages = [
51 | {
52 | text: `Overall, you pinged ${formatNumber(stats.messageStats.mentionCount)}
53 | ${usePlural('person, role or channel', stats.messageStats.mentionCount, 'people, roles and channels')}.`,
54 | value: stats.messageStats.mentionCount,
55 | icon: ,
56 | },
57 | {
58 | text: [
59 | `Message wasn't perfect? You edited your messages ${formatNumber(stats.eventStats.messageEdited)} ${usePlural('time', stats.eventStats.messageEdited)}.`,
60 | `That's ${formatNumber((stats.eventStats.messageEdited / stats.messageStats.messageCount) * 100)}% of your messages.`,
61 | ],
62 | value: stats.eventStats.messageEdited,
63 | icon: ,
64 | },
65 | {
66 | text: `Oops, looks like you deleted ${formatNumber(stats.eventStats.messageDeleted)} of your messages.`,
67 | value: stats.eventStats.messageDeleted,
68 | icon: ,
69 | },
70 | {
71 | text: [
72 | `A fan of emotes? You used a total of ${formatNumber(stats.messageStats.emoteCount)} in your messages.`,
73 | `That's one every ${Math.floor((stats.messageStats.messageCount / stats.messageStats.emoteCount) * 100) / 100} messages.`,
74 | ],
75 | value: stats.messageStats.emoteCount,
76 | icon: ,
77 | },
78 | {
79 | text: [
80 | `Or do you prefer default emoji? You used a total of ${formatNumber(stats.messageStats.emojiCount)} of those.`,
81 | `That's one every ${Math.floor((stats.messageStats.messageCount / stats.messageStats.emojiCount) * 100) / 100} messages.`,
82 | ],
83 | value: stats.messageStats.emojiCount,
84 | icon: ,
85 | },
86 | {
87 | text: `Reactions are a different story - you used ${formatNumber(stats.eventStats.reactionAdded)} of those.`,
88 | value: stats.eventStats.reactionAdded,
89 | icon: ,
90 | },
91 | {
92 | text: `You opened ${formatNumber(stats.eventStats.inviteOpened)}
93 | ${usePlural('invite', stats.eventStats.inviteOpened)}, and sent out ${formatNumber(stats.eventStats.inviteSent)} of your own.`,
94 | value: [
95 | stats.eventStats.inviteOpened, stats.eventStats.inviteSent,
96 | ],
97 | icon: ,
98 | },
99 | ];
100 |
101 | if (!shareable) {
102 | messages = messages.concat([
103 | {
104 | text: [
105 | `Sometimes everyone runs out of space: You ran into the message length limit ${formatNumber(stats.eventStats.messageLengthLimitReached)}
106 | ${usePlural('time', stats.eventStats.messageLengthLimitReached)}.`,
107 | `There's also a limit for reactions - you reached that one ${formatNumber(stats.eventStats.reactionLimitReached)}
108 | ${usePlural('time', stats.eventStats.reactionLimitReached)}.`,
109 | ],
110 | value: stats.eventStats.messageLengthLimitReached,
111 | icon: ,
112 | },
113 | {
114 | text: `You joined ${formatNumber(stats.eventStats.threadJoined)} threads.`,
115 | value: stats.eventStats.threadJoined,
116 | icon: ,
117 | },
118 | {
119 | text: `App commands can be quite useful - you've used ${formatNumber(stats.eventStats.slashCommandUsed)} of those.`,
120 | value: stats.eventStats.slashCommandUsed,
121 | icon: ,
122 | },
123 | {
124 | text: `See something you like? You saved ${formatNumber(stats.eventStats.imageSaved)} ${usePlural('image', stats.eventStats.imageSaved)} in Discord.`,
125 | value: stats.eventStats.imageSaved,
126 | icon: ,
127 | },
128 | ]);
129 | }
130 |
131 | return (
132 | <>
133 |
141 | {messages.map((message) => {
142 | if (Array.isArray(message.text)) {
143 | return ;
144 | }
145 | return ;
146 | })}
147 | >
148 | );
149 | };
150 |
151 | const getMetaDataFields = () => {
152 | let messages = [
153 | {
154 | text: stats.darkMode
155 | ? 'A dark mode connoisseur - unofficial stats say you\'re in the vast majority!'
156 | : 'A light mode user - that\'s pretty rare!',
157 | value: 'true', // no value check needed
158 | icon: ,
159 | },
160 | {
161 | text: `You uploaded a total of ${formatNumber(stats.filesUploaded)} files to Discord. That's a lot of memes!`,
162 | value: stats.filesUploaded,
163 | icon: ,
164 | },
165 | {
166 | text: `You opened Discord ${formatNumber(stats.eventStats.appOpened)} ${usePlural('time', stats.eventStats.appOpened)}.`,
167 | value: stats.eventStats.appOpened,
168 | icon: ,
169 | },
170 | {
171 | text: `Who rang? You clicked ${formatNumber(stats.eventStats.notificationClicked)} ${usePlural('notification', stats.eventStats.notificationClicked)}.`,
172 | value: stats.eventStats.notificationClicked,
173 | icon: ,
174 | },
175 | {
176 | text: `Any chance you're a famous streamer? You toggled streamer mode ${formatNumber(stats.eventStats.streamerModeToggled)}
177 | ${usePlural('time', stats.eventStats.streamerModeToggled)}.`,
178 | value: stats.eventStats.streamerModeToggled,
179 | icon: ,
180 | },
181 | {
182 | text: `A gamer, eh? Discord detected ${formatNumber(stats.eventStats.gameLaunched)} game
183 | ${usePlural('launch', stats.eventStats.gameLaunched, 'launches')}.`,
184 | value: stats.eventStats.gameLaunched,
185 | icon: ,
186 | },
187 | {
188 | text: [
189 | `Gotta stay up to date: You switched avatars ${formatNumber(stats.eventStats.avatarUpdated)}
190 | ${usePlural('time', stats.eventStats.avatarUpdated)}.`,
191 | `Same thing goes for your status: ${formatNumber(stats.eventStats.statusUpdated)} ${usePlural('update', stats.eventStats.statusUpdated)}.`,
192 | ],
193 | value: stats.eventStats.avatarUpdated,
194 | icon: ,
195 | },
196 | {
197 | text: `Uh-oh! Looks like Discord ran into ${formatNumber(stats.eventStats.errorDetected)}
198 | ${usePlural('error or crash', stats.eventStats.errorDetected, 'errors or crashes')} for you.`,
199 | value: stats.eventStats.errorDetected,
200 | icon: ,
201 | },
202 | {
203 | text: `Overall, Discord tried to sell you something ${formatNumber(stats.eventStats.promotionShown)}
204 | ${usePlural('time', stats.eventStats.promotionShown)}.`,
205 | value: stats.eventStats.promotionShown,
206 | icon: ,
207 | },
208 | ];
209 |
210 | if (!shareable) {
211 | messages = messages.concat([
212 | {
213 | text: `Looking for something? You started ${formatNumber(stats.eventStats.searchStarted)} ${usePlural('search', stats.eventStats.searchStarted, 'searches')}.`,
214 | value: stats.eventStats.searchStarted,
215 | icon: ,
216 | },
217 | {
218 | text: `Seems like you know your way around! You used ${formatNumber(stats.eventStats.keyboardShortcutUsed)} keyboard
219 | ${usePlural('shortcut', stats.eventStats.keyboardShortcutUsed)}.`,
220 | value: stats.eventStats.keyboardShortcutUsed,
221 | icon: ,
222 | },
223 | {
224 | text: `Thanks for keeping an eye out and reporting ${formatNumber(stats.eventStats.messageReported)}
225 | ${usePlural('message', stats.eventStats.messageReported)} and ${formatNumber(stats.eventStats.userReported)}
226 | ${usePlural('user', stats.eventStats.userReported)}.`,
227 | value: stats.eventStats.messageReported,
228 | icon: ,
229 | },
230 | {
231 | text: `In total, you spent $${formatNumber(stats.totalPaymentAmount / 100)} on Discord.`,
232 | value: 'true', // no value check needed, 0 is worth showing
233 | icon: ,
234 | },
235 | {
236 | text: `Based on their analytics, Discord thinks you're ${stats.eventStats.predictedAge?.predicted_age} years old and ${stats.eventStats.predictedGender?.predicted_gender} .`,
237 | value: Object.prototype.hasOwnProperty.call(stats.eventStats, 'predictedAge') && Object.prototype.hasOwnProperty.call(stats.eventStats, 'predictedGender'),
238 | icon:
239 | },
240 | {
241 | text: `According to Discord, you've been to ${stats.eventStats.countries?.length} different countries while using the platform.`,
242 | value: stats.eventStats.countries && stats.eventStats.countries.length > 0,
243 | icon:
244 | }
245 | ]);
246 | }
247 |
248 | return (
249 | <>
250 | {messages.map((message) => {
251 | if (Array.isArray(message.text)) {
252 | return ;
253 | }
254 | return ;
255 | })}
256 | >
257 | );
258 | };
259 |
260 | return (
261 |
262 | {
263 | stats && (
264 |
265 |
266 |
267 |
271 |
272 |
273 |
279 |
280 |
281 |
284 |
285 |
286 | {
287 | // don't show links in screenshot
288 | !shareable && (
289 |
290 |
291 | } />
292 |
293 |
294 | } />
295 |
296 |
297 | } />
298 |
299 |
300 | } />
301 |
302 |
303 | )
304 | }
305 |
306 |
307 | {getMessageDataFields()}
308 |
309 |
310 | {getMetaDataFields()}
311 |
312 |
313 |
314 |
315 |
320 |
321 |
322 |
323 | {
324 | shareable && (
325 |
326 |
327 | {'Get your own detailed Discord stats at '}
328 | discord-recap.com
329 | !
330 |
331 |
332 | {'Made by '}
333 | David B. Maier
334 |
335 |
336 | )
337 | }
338 |
339 | )
340 | }
341 |
342 | );
343 | }
344 |
--------------------------------------------------------------------------------
/app/routes/stats/servers/$serverID/$channelID.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useLoaderData } from 'remix';
3 |
4 | import { getStats } from '../../../../lib/store';
5 | import { cleanChartData } from '../../../../lib/utils';
6 | import Row from '../../../../components/Row';
7 | import Tile from '../../../../components/Tile';
8 | import MessageCount from '../../../../components/MessageCount';
9 | import FirstMessage from '../../../../components/FirstMessage';
10 | import MessageCharts from '../../../../components/MessageCharts';
11 | import TopWordsAndEmotes from '../../../../components/TopWordsAndEmotes';
12 | import BreadcrumbWrapper from '../../../../components/BreadcrumbWrapper';
13 |
14 | export const loader = async ({ params }) => ({ serverID: params.serverID, channelID: params.channelID });
15 |
16 | export default function ServerChannel() {
17 | const { serverID, channelID } = useLoaderData();
18 | const [stats, setStats] = useState(null);
19 |
20 | useEffect(() => {
21 | getStats().then((storedStats) => {
22 | const globalStats = JSON.parse(storedStats);
23 | const serverStats = globalStats.messageStats.serverMessages.servers.find((server) => server.id === serverID);
24 | serverStats.channels = [serverStats.channels.find((channel) => channel.id === channelID)];
25 | setStats(serverStats);
26 | });
27 | }, []);
28 |
29 | return (
30 |
31 | {
32 | stats && (
33 | <>
34 |
{`${stats.channels[0].unknown ? '' : '#'}${stats.channels[0].name} (${stats.name})`}
35 |
39 |
40 |
41 |
49 |
50 |
51 |
55 |
56 |
57 |
58 |
59 |
64 |
65 |
66 |
67 | >
68 | )
69 | }
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/app/routes/stats/servers/$serverID/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useLoaderData } from 'remix';
3 |
4 | import { GrCircleQuestion } from 'react-icons/gr';
5 |
6 | import { getStats } from '../../../../lib/store';
7 | import { cleanChartData } from '../../../../lib/utils';
8 | import Row from '../../../../components/Row';
9 | import Tile from '../../../../components/Tile';
10 | import MessageCount from '../../../../components/MessageCount';
11 | import FirstMessage from '../../../../components/FirstMessage';
12 | import MessageCharts from '../../../../components/MessageCharts';
13 | import TopWordsAndEmotes from '../../../../components/TopWordsAndEmotes';
14 | import TopList from '../../../../components/TopList';
15 | import Tooltip from '../../../../components/Tooltip';
16 | import BreadcrumbWrapper from '../../../../components/BreadcrumbWrapper';
17 |
18 | export const loader = async ({ params }) => params.serverID;
19 |
20 | export default function Server() {
21 | const serverID = useLoaderData();
22 | const [stats, setStats] = useState(null);
23 |
24 | useEffect(() => {
25 | getStats().then((storedStats) => {
26 | const globalStats = JSON.parse(storedStats);
27 | const serverStats = globalStats.messageStats.serverMessages.servers.find((server) => server.id === serverID);
28 | serverStats.channels = serverStats.channels.map((channel) => ({
29 | name: `${channel.name}`,
30 | id: channel.id,
31 | value: `${channel.messageCount} messages`,
32 | count: channel.messageCount,
33 | link: `/stats/servers/${serverID}/${channel.id}`,
34 | unknown: channel.unknown,
35 | firstMessage: channel.firstMessage,
36 | lastMessage: channel.lastMessage
37 | })).sort(({ count: value1 }, { count: value2 }) => value2 - value1);
38 | setStats(serverStats);
39 | });
40 | }, []);
41 |
42 | return (
43 |
44 | {
45 | stats && (
46 | <>
47 |
48 | {stats.name}
49 | {stats.unknown && (
50 | } text="All unknown channels are grouped in this category - Discord's data doesn't allow for more details." />
51 | )}
52 |
53 |
57 |
58 |
59 |
67 |
68 |
69 |
74 |
75 |
76 |
77 |
78 |
83 |
84 |
85 |
86 |
87 |
88 |
94 |
95 |
96 | >
97 | )
98 | }
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/app/routes/stats/servers/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 |
3 | import { MdGroups } from 'react-icons/md';
4 | import { BiFoodMenu } from 'react-icons/bi';
5 |
6 | import { getStats } from '../../../lib/store';
7 | import { cleanChartData, usePlural, formatNumber } from '../../../lib/utils';
8 | import TopList from '../../../components/TopList';
9 | import Row from '../../../components/Row';
10 | import Tile from '../../../components/Tile';
11 | import DataField from '../../../components/DataField';
12 | import MessageCount from '../../../components/MessageCount';
13 | import FirstMessage from '../../../components/FirstMessage';
14 | import MessageCharts from '../../../components/MessageCharts';
15 | import TopWordsAndEmotes from '../../../components/TopWordsAndEmotes';
16 | import BreadcrumbWrapper from '../../../components/BreadcrumbWrapper';
17 |
18 | export default function Servers() {
19 | const [stats, setStats] = useState(null);
20 |
21 | useEffect(() => {
22 | getStats().then((storedStats) => {
23 | const globalStats = JSON.parse(storedStats);
24 | const serverStats = globalStats.messageStats.serverMessages;
25 | serverStats.servers = serverStats.servers.map((server) => ({
26 | name: `${server.name}`,
27 | id: server.name || 'Unknown',
28 | value: `${server.messageCount} messages`,
29 | count: server.messageCount,
30 | link: `/stats/servers/${server.id}`,
31 | unknown: server.unknown,
32 | firstMessage: server.firstMessage,
33 | lastMessage: server.lastMessage
34 | })).sort(({ count: value1 }, { count: value2 }) => value2 - value1);
35 | setStats(serverStats);
36 | });
37 | }, []);
38 |
39 | return (
40 |
41 |
Your servers
42 |
46 | {stats && (
47 | <>
48 |
49 |
50 | ${formatNumber(stats.count)} ${usePlural('server', stats.count)}.`}
52 | subtitle={`Seems like you've muted ${formatNumber(stats.mutedCount)}
53 | ${usePlural('server', stats.mutedCount)} - that includes ones you're not in anymore.`}
54 | value={stats.count}
55 | icon={ }
56 | />
57 | ${formatNumber(stats.channelCount)} ${usePlural('channel', stats.channelCount)}.`}
59 | value={stats.channelCount}
60 | icon={ }
61 | />
62 |
63 |
64 |
72 |
73 |
74 |
80 |
81 |
82 |
83 |
84 |
89 |
90 |
91 |
92 |
93 |
94 |
100 |
101 |
102 | >
103 | )}
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/app/routes/stats/years.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 |
3 | import { getStats } from '../../lib/store';
4 | import { cleanChartData } from '../../lib/utils';
5 | import Row from '../../components/Row';
6 | import Tile from '../../components/Tile';
7 | import MessageCount from '../../components/MessageCount';
8 | import FirstMessage from '../../components/FirstMessage';
9 | import MessageCharts from '../../components/MessageCharts';
10 | import TopWordsAndEmotes from '../../components/TopWordsAndEmotes';
11 | import BreadcrumbWrapper from '../../components/BreadcrumbWrapper';
12 |
13 | export default function Years() {
14 | const [stats, setStats] = useState(null);
15 | const [year, setYear] = useState(null);
16 | const [yearStats, setYearStats] = useState(null);
17 | const [validYears, setValidYears] = useState([]);
18 |
19 | useEffect(() => {
20 | getStats().then((storedStats) => {
21 | const globalStats = JSON.parse(storedStats);
22 | const allYearStats = globalStats.messageStats.yearMessages;
23 | setStats(allYearStats);
24 |
25 | const tempValidYears = [];
26 | Object.entries(allYearStats).forEach(([specificYear, specificYearStats]) => {
27 | if (specificYearStats.messageCount > 0) {
28 | tempValidYears.push(specificYear);
29 | }
30 | });
31 | setValidYears(tempValidYears);
32 |
33 | const initialYear = tempValidYears[tempValidYears.length - 1];
34 | setYear(initialYear);
35 | setYearStats({ ...allYearStats[initialYear] });
36 | });
37 | }, []);
38 |
39 | const onYearChange = (newYear) => {
40 | setYear(newYear);
41 | setYearStats({ ...stats[newYear] });
42 | };
43 |
44 | return (
45 |
46 |
Your yearly stats
47 |
54 | { yearStats && (
55 | <>
56 |
57 |
58 |
66 |
67 |
68 |
74 |
75 |
76 |
77 |
78 |
82 |
83 |
84 |
85 | >
86 | )}
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/app/styles/shared.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Whitney";
3 | src: url("/Whitney.woff2") format("woff2");
4 | font-weight: normal;
5 | font-style: normal;
6 | }
7 |
8 | @font-face {
9 | font-family: "Whitney";
10 | src: url("/Whitney-Bold.woff2") format("woff2");
11 | font-weight: bold;
12 | font-style: normal;
13 | }
14 |
15 | :root {
16 | --header: #3b3d42;
17 | --background: #2c2f33;
18 | --dark-background: #23272a;
19 | --blurple: #5865f2;
20 | --white: #ffffff;
21 | --quote: #4f545c;
22 |
23 | height: 100%;
24 | }
25 |
26 | body,
27 | html {
28 | height: 100%;
29 | }
30 |
31 | body {
32 | background-color: var(--background);
33 | font-family: "Whitney", "Segoe UI", sans-serif;
34 | margin: 0;
35 | color: var(--white);
36 | }
37 |
38 | button {
39 | font-family: "Whitney", "Segoe UI", sans-serif;
40 | font-size: 1em;
41 | }
42 | button:hover {
43 | cursor: pointer;
44 | }
45 | button:focus {
46 | outline: none;
47 | border: 0;
48 | }
49 |
50 | a {
51 | color: var(--white);
52 | text-decoration: underline;
53 | text-decoration-color: var(--blurple);
54 | }
55 |
56 | b {
57 | color: var(--blurple);
58 | }
59 |
60 | h1 {
61 | display: flex;
62 | }
63 |
64 | h2 {
65 | font-size: 20px;
66 | margin: 0;
67 | font-weight: normal;
68 | }
69 |
70 | h3 {
71 | font-size: 15px;
72 | margin: 0;
73 | font-weight: normal;
74 | }
75 |
76 | p {
77 | margin: 0;
78 | }
79 |
80 | .dr-header {
81 | height: 50px;
82 | background-color: var(--header);
83 | display: flex;
84 | justify-content: center;
85 | align-items: center;
86 | margin-bottom: 15px;
87 | }
88 |
89 | .dr-footer {
90 | height: 50px;
91 | background-color: var(--header);
92 | margin-top: 15px;
93 | display: flex;
94 | justify-content: space-around;
95 | align-items: center;
96 | }
97 | .dr-footer button {
98 | margin: 5px;
99 | height: 30px;
100 | min-width: 70px;
101 | border-radius: 5px;
102 | background-color: var(--background);
103 | color: var(--white);
104 | border: none;
105 | text-align: center;
106 | }
107 | .dr-footer button:active {
108 | background-color: var(--blurple);
109 | }
110 |
111 | .dr-content-wrapper {
112 | background-color: var(--background);
113 | margin: auto;
114 | width: 90%;
115 | min-height: calc(100% - 140px);
116 | }
117 |
118 | #dr-share-wrapper {
119 | height: 0;
120 | overflow: hidden;
121 | }
122 |
123 | #dr-share-content {
124 | width: 1100px;
125 | background-color: var(--background);
126 | }
127 | #dr-share-content button,
128 | #dr-share-content .dr-toplist-header-tooltip {
129 | display: none;
130 | }
131 |
132 | .dr-row {
133 | width: 100%;
134 | border-bottom: 3px solid var(--dark-background);
135 | display: flex;
136 | flex-direction: row;
137 | flex-wrap: wrap;
138 | }
139 |
140 | .dr-tile {
141 | background-color: var(--dark-background);
142 | padding: 10px;
143 | margin: 10px;
144 | border-radius: 10px;
145 | }
146 |
147 | .dr-flex-1 {
148 | flex: 1 1 100px;
149 | }
150 | .dr-flex-2 {
151 | flex: 2 2 200px;
152 | }
153 | .dr-flex-3 {
154 | flex: 3 3 300px;
155 | }
156 | .dr-flex-4 {
157 | flex: 4 4 400px;
158 | }
159 |
160 | .dr-datafield {
161 | margin-bottom: 15px;
162 | display: flex;
163 | }
164 | .dr-datafield-icon {
165 | flex: 0 0 24px;
166 | margin-right: 10px;
167 | display: flex;
168 | justify-content: center;
169 | align-items: center;
170 | font-size: 20px;
171 | }
172 |
173 | .dr-firstmessage-content {
174 | margin-top: 10px;
175 | margin-bottom: 10px;
176 | border-left: 4px solid var(--quote);
177 | padding-left: 10px;
178 | }
179 |
180 | .dr-toplist-item {
181 | padding: 5px;
182 | border-bottom: 1px solid var(--quote);
183 | font-size: 20px;
184 | display: flex;
185 | justify-content: space-between;
186 | }
187 | .dr-toplist-item:nth-child(even) {
188 | background-color: var(--background);
189 | }
190 | .dr-toplist-expandable .dr-toplist-item:nth-child(even) {
191 | background-color: var(--dark-background);
192 | }
193 | .dr-toplist-expandable .dr-toplist-item:nth-child(odd) {
194 | background-color: var(--background);
195 | }
196 | .dr-toplist-item-name {
197 | line-height: 1.2em;
198 | }
199 | .dr-toplist-name-wrapper {
200 | display: flex;
201 | flex: 5;
202 | }
203 | .dr-toplist-number {
204 | flex: 0 0 60px;
205 | }
206 | .dr-toplist-header {
207 | display: flex;
208 | }
209 | .dr-toplist-header-tooltip {
210 | display: flex;
211 | justify-content: center;
212 | align-items: center;
213 | }
214 |
215 | .dr-messagecharts-controls {
216 | display: flex;
217 | justify-content: center;
218 | }
219 | .dr-messagecharts-button {
220 | margin: 5px;
221 | height: 30px;
222 | min-width: 70px;
223 | border-radius: 5px;
224 | background-color: var(--header);
225 | color: var(--white);
226 | border: none;
227 | text-align: center;
228 | }
229 | .dr-messagecharts-button:active {
230 | background-color: var(--blurple);
231 | }
232 | .dr-messagecharts-button-active {
233 | background-color: var(--blurple);
234 | }
235 |
236 | .dr-accordion-header {
237 | display: flex;
238 | justify-content: space-between;
239 | }
240 | .dr-accordion-select {
241 | margin-left: 5px;
242 | background-color: var(--quote);
243 | color: var(--white);
244 | border: 0;
245 | border-radius: 5px;
246 | min-width: 70px;
247 | min-height: 26px;
248 | font-size: 15px;
249 | }
250 | .dr-accordion-select:focus-visible {
251 | outline: none;
252 | }
253 | .dr-accordion-content {
254 | /* default max height that's unlikely to be reached */
255 | max-height: 2000px;
256 | overflow: hidden;
257 | transition: max-height 0.3s ease-in-out;
258 | }
259 | .dr-accordion-content-closed {
260 | max-height: 0;
261 | transition: max-height 0.3s ease-in-out;
262 | overflow: hidden;
263 | }
264 | .dr-accordion-toggle {
265 | margin: 5px;
266 | height: 30px;
267 | min-width: 70px;
268 | border-radius: 5px;
269 | background-color: var(--header);
270 | color: var(--white);
271 | border: none;
272 | text-align: center;
273 | }
274 | .dr-accordion-toggle:active {
275 | background-color: var(--blurple);
276 | }
277 | .dr-accordion-actions {
278 | display: flex;
279 | justify-content: center;
280 | }
281 |
282 | .dr-emote {
283 | vertical-align: middle;
284 | margin-left: 5px;
285 | margin-right: 5px;
286 | }
287 | .dr-emote-emoji {
288 | font-size: 18px;
289 | }
290 |
291 | .dr-usertile {
292 | display: flex;
293 | justify-content: center;
294 | align-items: center;
295 | height: 100%;
296 | }
297 |
298 | .dr-sectionlink {
299 | height: 100%;
300 | display: flex;
301 | justify-content: center;
302 | align-items: center;
303 | }
304 | .dr-sectionlink-icon {
305 | font-size: 30px;
306 | flex: 0 0 30px;
307 | display: flex;
308 | justify-content: center;
309 | align-items: center;
310 | margin-right: 15px;
311 | }
312 |
313 | .dr-landing-wrapper {
314 | display: flex;
315 | justify-content: center;
316 | align-items: flex-start;
317 | height: calc(100% - 55px);
318 | overflow-y: auto;
319 | }
320 | .dr-landing-tile {
321 | background-color: var(--background);
322 | padding: 10px;
323 | margin: 10px;
324 | margin-top: 70px;
325 | border-radius: 10px;
326 | width: 70%;
327 | display: flex;
328 | justify-content: center;
329 | align-items: center;
330 | flex-direction: column;
331 | }
332 | .dr-landing-button {
333 | margin: 5px;
334 | min-height: 35px;
335 | min-width: 70px;
336 | border-radius: 5px;
337 | background-color: var(--header);
338 | color: var(--white);
339 | border: none;
340 | text-align: center;
341 | }
342 | .dr-landing-button:active {
343 | background-color: var(--blurple);
344 | }
345 | .dr-landing-text {
346 | text-align: center;
347 | font-weight: bold;
348 | font-size: 20px;
349 | margin-bottom: 5px;
350 | }
351 | .dr-landing-instruction {
352 | text-align: center;
353 | font-size: 20px;
354 | margin-bottom: 15px;
355 | }
356 | .dr-landing-loading {
357 | display: flex;
358 | justify-content: center;
359 | align-items: center;
360 | }
361 | .dr-landing-loading-wrapper {
362 | display: flex;
363 | justify-content: center;
364 | align-items: center;
365 | min-height: 30px;
366 | }
367 | .dr-landing-loading-icon {
368 | margin-left: 10px;
369 | }
370 | .dr-landing-loading-icon svg {
371 | animation: dr-loading-icon 1.2s linear infinite;
372 | font-size: 30px;
373 | }
374 | @keyframes dr-loading-icon {
375 | 0% {
376 | transform: rotate(0deg);
377 | }
378 | 50% {
379 | transform: rotate(180deg);
380 | }
381 | 100% {
382 | transform: rotate(360deg);
383 | }
384 | }
385 | .dr-landing-footer {
386 | margin-top: 15px;
387 | height: 40px;
388 | background: var(--dark-background);
389 | width: 100%;
390 | display: flex;
391 | justify-content: center;
392 | align-items: center;
393 | }
394 | .dr-landing-paragraph {
395 | text-align: center;
396 | }
397 |
398 | .dr-breadcrumb-wrapper {
399 | display: flex;
400 | justify-content: space-between;
401 | align-items: center;
402 | font-size: 22px;
403 | }
404 | .dr-breadcrumb {
405 | margin-left: 20px;
406 | }
407 |
408 | .dr-yearselect-wrapper {
409 | display: flex;
410 | justify-content: center;
411 | align-content: center;
412 | }
413 | #dr-yearselect {
414 | margin-right: 20px;
415 | margin-left: 5px;
416 | background-color: var(--quote);
417 | color: var(--white);
418 | border: 0;
419 | border-radius: 5px;
420 | min-width: 70px;
421 | min-height: 26px;
422 | font-size: 15px;
423 | }
424 | #dr-yearselect:focus-visible {
425 | outline: none;
426 | }
427 |
428 | @media only screen and (max-width: 500px) {
429 | .dr-breadcrumb-wrapper {
430 | font-size: 16px;
431 | }
432 | .dr-yearselect-wrapper span {
433 | display: none;
434 | }
435 | }
436 |
437 | .dr-tooltip {
438 | position: relative;
439 | display: flex;
440 | justify-content: center;
441 | align-items: center;
442 | margin-left: 5px;
443 | margin-right: 5px;
444 | }
445 | .dr-tooltip svg path {
446 | stroke: var(--white);
447 | }
448 | .dr-tooltip .dr-tooltip-text {
449 | visibility: hidden;
450 | width: 150px;
451 | font-size: 15px;
452 | background-color: black;
453 | color: #fff;
454 | text-align: center;
455 | padding: 5px 0;
456 | border-radius: 5px;
457 |
458 | position: absolute;
459 | z-index: 1;
460 | }
461 | .dr-tooltip:hover .dr-tooltip-text {
462 | visibility: visible;
463 | }
464 | .dr-tooltip .dr-tooltip-right {
465 | left: 105%;
466 | }
467 | .dr-tooltip .dr-tooltip-left {
468 | right: 105%;
469 | }
470 |
471 | .dr-connections-item {
472 | display: flex;
473 | justify-content: space-between;
474 | padding: 2px;
475 | }
476 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react-jsx",
4 | "baseUrl": ".",
5 | "paths": {
6 | "~/*": ["./app/*"]
7 | },
8 | "allowJs": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
11 | "strict": true,
12 | "target": "ES2019",
13 | "esModuleInterop": true,
14 | "isolatedModules": true,
15 | "moduleResolution": "node",
16 | "noEmit": true,
17 | "resolveJsonModule": true
18 | },
19 | "exclude": ["node_modules"],
20 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"]
21 | }
22 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | command = "remix build"
3 | functions = "netlify/functions"
4 | publish = "public"
5 |
6 | [dev]
7 | command = "remix watch"
8 | port = 3000
9 |
10 | [[redirects]]
11 | from = "/*"
12 | to = "/.netlify/functions/server"
13 | status = 200
14 |
15 | [[headers]]
16 | for = "/build/*"
17 | [headers.values]
18 | "Cache-Control" = "public, max-age=31536000, s-maxage=31536000"
19 |
--------------------------------------------------------------------------------
/netlify/functions/server/index.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const { createRequestHandler } = require("@remix-run/netlify");
3 |
4 | const BUILD_DIR = path.join(process.cwd(), "netlify");
5 |
6 | function purgeRequireCache() {
7 | // purge require cache on requests for "server side HMR" this won't let
8 | // you have in-memory objects between requests in development,
9 | // netlify typically does this for you, but we've found it to be hit or
10 | // miss and some times requires you to refresh the page after it auto reloads
11 | // or even have to restart your server
12 | for (const key in require.cache) {
13 | if (key.startsWith(BUILD_DIR)) {
14 | delete require.cache[key];
15 | }
16 | }
17 | }
18 |
19 | exports.handler =
20 | process.env.NODE_ENV === "production"
21 | ? createRequestHandler({ build: require("./build") })
22 | : (event, context) => {
23 | purgeRequireCache();
24 | return createRequestHandler({ build: require("./build") })(
25 | event,
26 | context
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "discord-recap",
4 | "description": "",
5 | "license": "MIT",
6 | "scripts": {
7 | "build": "remix build",
8 | "dev": "cross-env NODE_ENV=development netlify dev",
9 | "postinstall": "remix setup node"
10 | },
11 | "dependencies": {
12 | "@netlify/functions": "1.0.0",
13 | "@remix-run/netlify": "1.6.0",
14 | "@remix-run/react": "1.6.0",
15 | "emoji-regex": "^9.2.2",
16 | "fflate": "^0.7.2",
17 | "html2canvas": "^1.4.1",
18 | "idb-keyval": "^6.1.0",
19 | "papaparse": "^5.3.1",
20 | "prop-types": "^15.8.0",
21 | "react": "^17.0.2",
22 | "react-dom": "^17.0.2",
23 | "react-icons": "^4.3.1",
24 | "recharts": "^2.7.2",
25 | "remix": "1.6.0"
26 | },
27 | "devDependencies": {
28 | "@remix-run/dev": "1.6.0",
29 | "cross-env": "^7.0.3",
30 | "eslint": "^8.5.0",
31 | "eslint-config-airbnb": "^19.0.4",
32 | "eslint-plugin-import": "^2.25.3",
33 | "eslint-plugin-jsx-a11y": "^6.5.1",
34 | "eslint-plugin-react": "^7.28.0",
35 | "eslint-plugin-react-hooks": "^4.3.0",
36 | "netlify-cli": "^17.33.4"
37 | },
38 | "engines": {
39 | "node": ">=14"
40 | },
41 | "sideEffects": false
42 | }
43 |
--------------------------------------------------------------------------------
/public/Whitney-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidbmaier/discord-recap/c72f85b20564c3ad5599e8f16ccb4ba4ba79a169/public/Whitney-Bold.woff2
--------------------------------------------------------------------------------
/public/Whitney.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidbmaier/discord-recap/c72f85b20564c3ad5599e8f16ccb4ba4ba79a169/public/Whitney.woff2
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidbmaier/discord-recap/c72f85b20564c3ad5599e8f16ccb4ba4ba79a169/public/favicon.ico
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@remix-run/dev/config').AppConfig}
3 | */
4 | module.exports = {
5 | appDirectory: "app",
6 | assetsBuildDirectory: "public/build",
7 | publicPath: "/build/",
8 | serverBuildDirectory: "netlify/functions/server/build",
9 | devServerPort: 8002,
10 | ignoredRouteFiles: [".*"]
11 | };
12 |
--------------------------------------------------------------------------------