├── .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 | ![image](https://user-images.githubusercontent.com/17618532/148423923-a0dbd358-54d9-4e4c-a5ee-8a3f55622f9b.png) 10 | ![image](https://user-images.githubusercontent.com/17618532/148423958-d45c3da9-3415-493f-8b29-8a9c66547f66.png) 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 |
32 |
33 | {header} 34 | { 35 | sortable 36 | ? 37 | Sort by 38 | 46 | 47 | : <> 48 | } 49 |
50 |
51 | {content} 52 |
53 | { 54 | onToggle && ( 55 |
56 | 63 |
64 | 65 | ) 66 | } 67 |
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 |
12 | 13 | {breadcrumbText} 14 | 15 | 16 | { onFilter && ( 17 | <> 18 | Filter by year: 19 | 24 | 25 | )} 26 | 27 |
28 | ); 29 | }; 30 | 31 | BreadcrumbWrapper.propTypes = { 32 | breadcrumbText: PropTypes.string.isRequired, 33 | breadcrumbLink: PropTypes.string.isRequired, 34 | onFilter: PropTypes.func, 35 | validOptions: PropTypes.arrayOf(PropTypes.string), 36 | currentSelection: PropTypes.string, 37 | }; 38 | 39 | export default BreadcrumbWrapper; 40 | -------------------------------------------------------------------------------- /app/components/Chart.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | ResponsiveContainer, BarChart, XAxis, YAxis, Tooltip, Legend, Bar, 5 | } from 'recharts'; 6 | 7 | import { chartTypes, dayLabels } from '../lib/constants'; 8 | 9 | const Chart = (props) => { 10 | const { data, type } = props; 11 | 12 | const formatTooltipValue = (value) => [`${formatNumber(value)} Messages`, '']; 13 | 14 | const formatNumber = (value) => { 15 | const numberFormat = new Intl.NumberFormat('en-US'); 16 | return numberFormat.format(value); 17 | }; 18 | 19 | const formatLabel = (label) => { 20 | if (type === chartTypes.hour) { 21 | const intLabel = parseInt(label, 10); 22 | if (intLabel < 12) { 23 | return `${intLabel || 12}am`; 24 | } 25 | return `${intLabel - 12 || 12}pm`; 26 | } if (type === chartTypes.day) { 27 | return dayLabels[label]; 28 | } 29 | return `${label}`; 30 | }; 31 | 32 | return ( 33 | 34 | 37 | 38 | 39 | 49 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | Chart.propTypes = { 57 | type: PropTypes.string.isRequired, 58 | data: PropTypes.arrayOf(PropTypes.shape({ 59 | category: PropTypes.string.isRequired, 60 | count: PropTypes.number.isRequired, 61 | })).isRequired, 62 | }; 63 | 64 | export default Chart; 65 | -------------------------------------------------------------------------------- /app/components/Connections.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Connections = (props) => { 5 | const { connections } = props; 6 | 7 | const capitalizeFirstLetter = (name) => name.charAt(0).toUpperCase() + name.slice(1); 8 | 9 | return ( 10 |
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 |
22 | { 23 | isValid() && ( 24 |
25 | {icon} 26 | 27 |
28 |

29 |

30 | {subtitle && ( 31 |
32 |

33 |

34 | )} 35 |
36 | 37 |
38 | ) 39 | } 40 |
41 | 42 | ); 43 | }; 44 | 45 | DataField.propTypes = { 46 | valueText: PropTypes.string.isRequired, 47 | subtitle: PropTypes.string, 48 | value: PropTypes.oneOfType( 49 | [ 50 | PropTypes.string, 51 | PropTypes.arrayOf(PropTypes.string), 52 | PropTypes.number, 53 | PropTypes.arrayOf(PropTypes.number), 54 | PropTypes.bool 55 | ], 56 | ), 57 | icon: PropTypes.node, 58 | }; 59 | 60 | export default DataField; 61 | -------------------------------------------------------------------------------- /app/components/Emote.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import emojiRegex from 'emoji-regex'; 4 | 5 | const Emote = (props) => { 6 | const { id, name, size = 22 } = props; 7 | if (name.match(emojiRegex())) { 8 | return ( 9 | 10 | {name} 11 | 12 | ); 13 | } 14 | if (!id) { 15 | return ; 16 | } 17 | return ( 18 | 19 | ); 20 | }; 21 | 22 | Emote.propTypes = { 23 | id: PropTypes.string, 24 | name: PropTypes.string.isRequired, 25 | size: PropTypes.number, 26 | }; 27 | 28 | export default Emote; 29 | -------------------------------------------------------------------------------- /app/components/FirstMessage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Link } from 'remix'; 5 | import { channelTypes, emoteRegex } from '../lib/constants'; 6 | import Emote from './Emote'; 7 | 8 | const FirstMessage = (props) => { 9 | const { 10 | message, context, showChannel, showServer, 11 | } = props; 12 | 13 | const formatDate = (date) => { 14 | const dateObject = new Date(date); 15 | return dateObject.toLocaleString('en-US', { 16 | month: 'long', 17 | day: 'numeric', 18 | year: 'numeric', 19 | }); 20 | }; 21 | 22 | const formatMessage = (content) => { 23 | let fragmentIndex = 0; 24 | const emoteMatches = content.matchAll(emoteRegex); 25 | const formattedMessageFragments = []; 26 | let startIndex = 0; 27 | // eslint-disable-next-line no-restricted-syntax -- matchAll returns an iterator 28 | for (const emoteMatch of emoteMatches) { 29 | // add the text before the emote 30 | formattedMessageFragments.push(content.substring(startIndex, emoteMatch.index)); 31 | formattedMessageFragments.push(); 32 | startIndex = emoteMatch.index + emoteMatch[0].length; 33 | fragmentIndex += 1; 34 | } 35 | // add the remaining message 36 | formattedMessageFragments.push(content.substr(startIndex)); 37 | 38 | // replace \n with real linebreaks 39 | const formattedMessageFragmentsWithLineBreaks = formattedMessageFragments.map((fragment) => { 40 | if (typeof fragment === 'string') { 41 | const lines = fragment.split('\n'); 42 | const linesWithBreaks = []; 43 | lines.forEach((line, index) => { 44 | if (index === lines.length - 1) { 45 | linesWithBreaks.push(line); 46 | } else { 47 | linesWithBreaks.push(line); 48 | linesWithBreaks.push(
); 49 | fragmentIndex += 1; 50 | } 51 | }); 52 | return linesWithBreaks; 53 | } 54 | return fragment; 55 | }).flat(); 56 | 57 | return formattedMessageFragmentsWithLineBreaks; 58 | }; 59 | 60 | const getReference = () => { 61 | const referenceParts = [ 62 | `Sent on ${formatDate(message.date)}`, 63 | ]; 64 | 65 | if (showChannel) { 66 | const isDM = message.channel.type === channelTypes.DM || message.channel.type === channelTypes.groupDM; 67 | 68 | const channelLink = isDM ? `/stats/dms/${message.channel.id}` : `/stats/servers/${message.channel?.guild?.id}/${message.channel.id}`; 69 | referenceParts.push(' in '); 70 | referenceParts.push({`${!isDM && !message.channel.unknown ? '#' : ''}${message.channel.name}`}); 71 | } 72 | if (showServer && message.channel.guild) { 73 | referenceParts.push(` (${message.channel.guild.name})`); 74 | } 75 | 76 | return referenceParts.map((paragraph) => ( 77 | {paragraph} 78 | )); 79 | }; 80 | 81 | return ( 82 |
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 |
33 | 34 |
35 | 42 | 49 | { 50 | messageCountPerYear && ( 51 | 58 | ) 59 | } 60 |
61 |
62 | ); 63 | }; 64 | 65 | MessageCharts.propTypes = { 66 | messageCountPerDay: PropTypes.arrayOf(PropTypes.shape({ 67 | category: PropTypes.string.isRequired, 68 | count: PropTypes.number.isRequired, 69 | })).isRequired, 70 | messageCountPerHour: PropTypes.arrayOf(PropTypes.shape({ 71 | category: PropTypes.string.isRequired, 72 | count: PropTypes.number.isRequired, 73 | })).isRequired, 74 | messageCountPerYear: PropTypes.arrayOf(PropTypes.shape({ 75 | category: PropTypes.string.isRequired, 76 | count: PropTypes.number.isRequired, 77 | })), 78 | }; 79 | 80 | export default MessageCharts; 81 | -------------------------------------------------------------------------------- /app/components/MessageCount.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 | import { TiMessages } from 'react-icons/ti'; 6 | import { BiBook } from 'react-icons/bi'; 7 | import { BsFileText } from 'react-icons/bs'; 8 | 9 | import DataField from './DataField'; 10 | import { usePlural, formatNumber } from '../lib/utils'; 11 | 12 | const MessageCount = (props) => { 13 | const { 14 | messageCount, wordCount, characterCount, firstMessage, lastMessage, context, 15 | } = props; 16 | 17 | const getAverageMessageCountPerDay = () => { 18 | const daysSinceFirstMessage = (Date.now() - new Date(firstMessage.date).getTime()) / (1000 * 60 * 60 * 24); 19 | const daysSinceLastMessage = (Date.now() - new Date(lastMessage.date).getTime()) / (1000 * 60 * 60 * 24); 20 | const daysBetweenMessages = daysSinceFirstMessage - daysSinceLastMessage; 21 | return Math.floor((messageCount / daysBetweenMessages) * 100) / 100; 22 | }; 23 | 24 | return ( 25 |
26 | ${formatNumber(messageCount)} 28 | ${usePlural('message', messageCount)}${context ? ` ${context}` : ''}.`} 29 | subtitle={ 30 | messageCount > 1 31 | ? `That's about ${getAverageMessageCountPerDay()} 32 | ${usePlural('message', getAverageMessageCountPerDay())} per day between your first and your latest one.` 33 | : `` 34 | } 35 | value={messageCount} 36 | icon={} 37 | /> 38 | ${formatNumber(wordCount)} ${usePlural('word', wordCount)}.`} 40 | value={wordCount} 41 | icon={} 42 | /> 43 | ${formatNumber(characterCount)} ${usePlural('character', characterCount)}.`} 45 | value={characterCount} 46 | icon={} 47 | /> 48 |
49 | ); 50 | }; 51 | 52 | MessageCount.propTypes = { 53 | messageCount: PropTypes.number.isRequired, 54 | wordCount: PropTypes.number.isRequired, 55 | characterCount: PropTypes.number.isRequired, 56 | firstMessage: PropTypes.shape({ 57 | date: PropTypes.string.isRequired, 58 | }).isRequired, 59 | lastMessage: PropTypes.shape({ 60 | date: PropTypes.string.isRequired, 61 | }).isRequired, 62 | context: PropTypes.string.isRequired, 63 | }; 64 | 65 | export default MessageCount; 66 | -------------------------------------------------------------------------------- /app/components/Row.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Row = (props) => { 5 | const { children } = props; 6 | 7 | return ( 8 |
9 | {children} 10 |
11 | ); 12 | }; 13 | 14 | Row.propTypes = { 15 | children: PropTypes.node.isRequired, 16 | }; 17 | 18 | export default Row; 19 | -------------------------------------------------------------------------------- /app/components/SectionLink.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 | import { Link } from 'remix'; 5 | 6 | const DataField = (props) => { 7 | const { title, link, icon } = props; 8 | 9 | return ( 10 |

11 | 12 | {icon} 13 | 14 | 15 | {title} 16 | 17 |

18 | 19 | ); 20 | }; 21 | 22 | DataField.propTypes = { 23 | title: PropTypes.string.isRequired, 24 | link: PropTypes.string.isRequired, 25 | icon: PropTypes.node.isRequired, 26 | }; 27 | 28 | export default DataField; 29 | -------------------------------------------------------------------------------- /app/components/Tile.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Tile = (props) => { 5 | const { children, flex } = props; 6 | 7 | const getClassNames = () => { 8 | const classNames = ['dr-tile']; 9 | classNames.push(`dr-flex-${flex}`); 10 | 11 | return classNames.join(' '); 12 | }; 13 | 14 | return ( 15 |
16 | {children} 17 |
18 | ); 19 | }; 20 | 21 | Tile.propTypes = { 22 | children: PropTypes.node.isRequired, 23 | flex: PropTypes.number.isRequired, 24 | }; 25 | 26 | export default Tile; 27 | -------------------------------------------------------------------------------- /app/components/Tooltip.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Tooltip = (props) => { 5 | const { icon, text, side } = props; 6 | 7 | const getClassNames = () => { 8 | let classNames = 'dr-tooltip-text'; 9 | if (side === 'left') { 10 | classNames += ' dr-tooltip-left'; 11 | } else { 12 | classNames += ' dr-tooltip-right'; 13 | } 14 | return classNames; 15 | }; 16 | 17 | return ( 18 | 19 | {icon} 20 | {text} 21 | 22 | ); 23 | }; 24 | 25 | Tooltip.propTypes = { 26 | icon: PropTypes.node.isRequired, 27 | text: PropTypes.string.isRequired, 28 | side: PropTypes.oneOf(['left', 'right']), 29 | }; 30 | 31 | export default Tooltip; 32 | -------------------------------------------------------------------------------- /app/components/TopList.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'remix'; 4 | import emojiRegex from 'emoji-regex'; 5 | 6 | import { GrCircleQuestion } from 'react-icons/gr'; 7 | 8 | import Accordion from './Accordion'; 9 | import Emote from './Emote'; 10 | import Tooltip from './Tooltip'; 11 | 12 | const sortOptions = [ 13 | { key: `countDesc`, label: `Message count (descending)` }, 14 | { key: `countAsc`, label: `Message count (ascending)` }, 15 | { key: `latestDesc`, label: `Latest message sent (newest first)` }, 16 | { key: `latestAsc`, label: `Latest message sent (oldest first)` }, 17 | { key: `firstDesc`, label: `First message sent (newest first)` }, 18 | { key: `firstAsc`, label: `First message sent (oldest first)` }, 19 | ]; 20 | 21 | const TopList = (props) => { 22 | const { 23 | title, tooltip, items, onToggle, open, ignoreEmoji, expandable, sortable 24 | } = props; 25 | 26 | const [contentRef, setContentRef] = useState(null); 27 | const [sortMode, setSortMode] = useState(`countDesc`); 28 | const [sortedItems, setSortedItems] = useState([]); 29 | 30 | useEffect(() => { 31 | if (contentRef) { 32 | if (open) { 33 | contentRef.setAttribute('style', `max-height: ${items.length * 35}px`); 34 | } else { 35 | contentRef.setAttribute('style', 'max-height: 0px'); 36 | } 37 | } 38 | }, [open, contentRef, items]); 39 | 40 | useEffect(() => { 41 | setSortedItems(items); 42 | }, [items]) 43 | 44 | const onAccordionRefChange = (ref) => { 45 | setContentRef(ref); 46 | }; 47 | 48 | const getName = (name) => { 49 | if (ignoreEmoji && name.match(emojiRegex())) { 50 | return ''; 51 | } 52 | return name; 53 | }; 54 | 55 | const onSort = (newSortMode) => { 56 | if (newSortMode === sortMode) { 57 | return; 58 | } 59 | 60 | const itemsToBeSorted = [...items]; 61 | setSortMode(newSortMode); 62 | 63 | switch (newSortMode) { 64 | case `countDesc`: 65 | setSortedItems(itemsToBeSorted); 66 | break; 67 | case `countAsc`: 68 | setSortedItems(itemsToBeSorted.reverse()); 69 | break; 70 | case `latestDesc`: 71 | itemsToBeSorted.sort((channelA, channelB) => { 72 | return new Date(channelB.lastMessage?.date || 0) - new Date(channelA.lastMessage?.date || 0) 73 | }) 74 | setSortedItems(itemsToBeSorted); 75 | break; 76 | case `latestAsc`: 77 | itemsToBeSorted.sort((channelA, channelB) => { 78 | return new Date(channelA.lastMessage?.date || 0) - new Date(channelB.lastMessage?.date || 0) 79 | }) 80 | setSortedItems(itemsToBeSorted); 81 | break; 82 | case `firstDesc`: 83 | itemsToBeSorted.sort((channelA, channelB) => { 84 | return new Date(channelB.firstMessage?.date || 0) - new Date(channelA.firstMessage?.date || 0) 85 | }) 86 | setSortedItems(itemsToBeSorted); 87 | break; 88 | case `firstAsc`: 89 | itemsToBeSorted.sort((channelA, channelB) => { 90 | return new Date(channelA.firstMessage?.date || 0) - new Date(channelB.firstMessage?.date || 0) 91 | }) 92 | setSortedItems(itemsToBeSorted); 93 | break; 94 | default: 95 | break; 96 | } 97 | } 98 | 99 | return ( 100 |
101 | 105 | {title} 106 | { 107 | tooltip && ( 108 | 109 | } text={tooltip} /> 110 | 111 | ) 112 | } 113 | 114 | )} 115 | content={( 116 |
117 | { 118 | sortedItems.map((item, index) => ( 119 |
120 | 121 | 122 | {item.index || index + 1} 123 | 124 | { 125 | item.icon && ( 126 | 127 | ) 128 | } 129 | { 130 | item.link 131 | ? {getName(item.name)} 132 | : ( 133 | 134 | {getName(item.name)} 135 | 136 | ) 137 | } 138 | { 139 | item.unknown && ( 140 | } text="Either you left, or it got deleted." /> 141 | ) 142 | } 143 | 144 | 145 | {item.value} 146 | 147 |
148 | )) 149 | } 150 |
151 | )} 152 | onToggle={onToggle} 153 | open={open} 154 | onRefChange={onAccordionRefChange} 155 | sortable={sortable} 156 | sortOptions={sortOptions} 157 | onSort={onSort} 158 | /> 159 |
160 | ); 161 | }; 162 | 163 | TopList.propTypes = { 164 | items: PropTypes.arrayOf(PropTypes.shape({ 165 | id: PropTypes.string.isRequired, 166 | name: PropTypes.string.isRequired, 167 | value: PropTypes.string.isRequired, 168 | link: PropTypes.string, 169 | unknown: PropTypes.bool, 170 | })).isRequired, 171 | title: PropTypes.string.isRequired, 172 | tooltip: PropTypes.string, 173 | onToggle: PropTypes.func, 174 | open: PropTypes.bool, 175 | ignoreEmoji: PropTypes.bool, 176 | expandable: PropTypes.bool, 177 | sortable: PropTypes.bool, 178 | }; 179 | 180 | export default TopList; 181 | -------------------------------------------------------------------------------- /app/components/TopWordsAndEmotes.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Row from './Row'; 5 | import Tile from './Tile'; 6 | import TopList from './TopList'; 7 | 8 | const TopWordsAndEmotes = (props) => { 9 | const { 10 | topWords, topEmotes, shareable, 11 | } = props; 12 | 13 | const [emotesOpen, setEmotesOpen] = useState(shareable); 14 | const [wordsOpen, setWordsOpen] = useState(shareable); 15 | 16 | const getTopWords = () => { 17 | const words = topWords.map( 18 | ({ name, count }, index) => ({ 19 | name, value: `${count}`, id: name, index: index + 1, 20 | }), 21 | ); 22 | if (shareable) { 23 | return words.slice(0, 5); 24 | } 25 | return words; 26 | }; 27 | 28 | const getTopEmotes = () => { 29 | const emotes = topEmotes.map( 30 | ({ name, count, id }, index) => ({ 31 | // default emoji don't have an id - but "id" maps to the key attribute 32 | name, value: `${count}`, id: name, emoteID: id, icon: true, index: index + 1, 33 | }), 34 | ); 35 | if (shareable) { 36 | return emotes.slice(0, 5); 37 | } 38 | return emotes; 39 | }; 40 | 41 | return ( 42 | 43 | 44 | 50 | { 56 | setWordsOpen(!wordsOpen); 57 | setEmotesOpen(!wordsOpen); 58 | }} 59 | /> 60 | 61 | 62 | 68 | { 74 | setEmotesOpen(!emotesOpen); 75 | setWordsOpen(!emotesOpen); 76 | }} 77 | ignoreEmoji 78 | /> 79 | 80 | 81 | ); 82 | }; 83 | 84 | TopWordsAndEmotes.propTypes = { 85 | topWords: PropTypes.arrayOf(PropTypes.shape({ 86 | name: PropTypes.string.isRequired, 87 | count: PropTypes.number.isRequired, 88 | })).isRequired, 89 | topEmotes: PropTypes.arrayOf(PropTypes.shape({ 90 | name: PropTypes.string.isRequired, 91 | count: PropTypes.number.isRequired, 92 | id: PropTypes.string, 93 | })).isRequired, 94 | shareable: PropTypes.bool, 95 | }; 96 | 97 | export default TopWordsAndEmotes; 98 | -------------------------------------------------------------------------------- /app/components/UserTile.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const UserTile = (props) => { 5 | const { userTag } = props; 6 | 7 | return ( 8 |
9 |

{userTag}

10 |
11 | ); 12 | }; 13 | 14 | UserTile.propTypes = { 15 | userTag: PropTypes.string.isRequired, 16 | }; 17 | 18 | export default UserTile; 19 | -------------------------------------------------------------------------------- /app/entry.client.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { hydrate } from 'react-dom'; 3 | import { RemixBrowser } from 'remix'; 4 | 5 | hydrate(, document); 6 | -------------------------------------------------------------------------------- /app/entry.server.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderToString } from 'react-dom/server'; 3 | import { RemixServer } from 'remix'; 4 | 5 | export default function handleRequest( 6 | request, 7 | responseStatusCode, 8 | responseHeaders, 9 | remixContext, 10 | ) { 11 | const markup = renderToString( 12 | , 13 | ); 14 | 15 | responseHeaders.set('Content-Type', 'text/html'); 16 | 17 | return new Response(`${markup}`, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/lib/constants.js: -------------------------------------------------------------------------------- 1 | export const requiredFiles = [ 2 | { 3 | name: `README.txt`, 4 | value: /README\.txt/ 5 | }, 6 | { 7 | name: `user.json in /account`, 8 | value: /account\/user\.json/ 9 | }, 10 | { 11 | name: `index.json in /messages`, 12 | value: /messages\/index\.json/ 13 | }, 14 | { 15 | name: `index.json in /servers`, 16 | value: /servers\/index\.json/ 17 | }, 18 | { 19 | name: `events.json in /activity`, 20 | value: /activity\/(analytics|tns|modeling)\/events-[0-9-of]+\.json/ 21 | } 22 | ]; 23 | 24 | export const channelTypes = { 25 | textChannel: 0, 26 | DM: 1, 27 | groupDM: 3, 28 | newsChannel: 5, 29 | newsThread: 10, 30 | publicThread: 11, 31 | privateThread: 12, 32 | }; 33 | 34 | export const relationshipTypes = { 35 | friend: 1, 36 | blocked: 2, 37 | }; 38 | 39 | export const getBaseHours = () => { 40 | const baseHours = {}; 41 | for (let i = 0; i < 24; i += 1) { 42 | baseHours[i] = 0; 43 | } 44 | return baseHours; 45 | }; 46 | 47 | export const getBaseDays = () => { 48 | const baseDays = {}; 49 | for (let i = 0; i < 7; i += 1) { 50 | baseDays[i] = 0; 51 | } 52 | return baseDays; 53 | }; 54 | 55 | export const getBaseYears = () => { 56 | const baseYears = {}; 57 | // Discord was founded in 2015 58 | for (let i = 2015; i < new Date().getFullYear() + 1; i += 1) { 59 | baseYears[i] = 0; 60 | } 61 | return baseYears; 62 | }; 63 | 64 | export const getBaseStats = () => ({ 65 | messageCount: 0, 66 | wordCount: 0, 67 | characterCount: 0, 68 | firstMessage: null, 69 | topWords: {}, 70 | topEmotes: {}, 71 | messageCountPerHour: getBaseHours(), 72 | messageCountPerDay: getBaseDays(), 73 | messageCountPerYear: getBaseYears(), 74 | }); 75 | 76 | export const reactionEventTypes = { 77 | reactionAdded: 'add_reaction', 78 | reactionRemoved: 'remove_reaction', 79 | reactionLimitReached: 'reaction_limit_reached', 80 | }; 81 | export const externalEventTypes = { 82 | notificationClicked: 'notification_clicked', 83 | appOpened: 'app_opened', 84 | gameLaunched: 'launch_game', 85 | }; 86 | export const appEventTypes = { 87 | searchStarted: 'search_started', 88 | keyboardShortcutUsed: 'keyboard_shortcut_used', 89 | streamerModeToggled: 'streamer_mode_toggle', 90 | imageSaved: 'context_menu_image_saved', 91 | statusUpdated: 'custom_status_updated', 92 | avatarUpdated: 'user_avatar_updated', 93 | messageReported: 'message_reported', 94 | userReported: 'user_report_submitted', 95 | }; 96 | export const linkEventTypes = { 97 | inviteOpened: 'invite_opened', 98 | inviteSent: 'invite_sent', 99 | giftSent: 'gift_code_sent', 100 | }; 101 | export const messageEventTypes = { 102 | messageEdited: 'message_edited', 103 | messageDeleted: 'message_deleted', 104 | messageLengthLimitReached: 'message_length_limit_reached', 105 | threadJoined: 'join_thread', 106 | slashCommandUsed: 'application_command_used', 107 | }; 108 | export const voiceEventTypes = { 109 | voiceChannelJoined: 'join_voice_channel', 110 | voiceDMJoined: 'join_call', 111 | startedSpeaking: 'start_speaking', 112 | }; 113 | export const promotionEventTypes = { 114 | premiumUpsellViewed: 'premium_upsell_viewed', 115 | premiumGuildUpsellViewed: 'premium_guild_upsell_viewed', 116 | premiumGuildPromotionOpened: 'premium_guild_promotion_opened', 117 | premiumPageOpened: 'premium_page_opened', 118 | premiumPromotionOpened: 'premium_promotion_opened', 119 | premiumMarketingViewed: 'premium_marketing_page_viewed', 120 | promotionViewed: 'promotion_viewed', 121 | upsellViewed: 'upsell_viewed', 122 | upsellClicked: 'upsell_clicked', 123 | outboundPromotionClicked: 'outbound_promotion_notice_clicked', 124 | emojiUpsellClicked: 'emoji_upsell_popout_more_emojis_opened', 125 | marketingPageViewed: 'mktg_page_viewed', 126 | }; 127 | export const technicalEventTypes = { 128 | nativeAppCrashed: 'app_native_crash', 129 | appCrashed: 'app_crashed', 130 | exceptionThrown: 'app_exception_thrown', 131 | }; 132 | 133 | export const eventTypes = { 134 | ...reactionEventTypes, 135 | ...externalEventTypes, 136 | ...appEventTypes, 137 | ...linkEventTypes, 138 | ...messageEventTypes, 139 | ...voiceEventTypes, 140 | ...promotionEventTypes, 141 | ...technicalEventTypes, 142 | }; 143 | 144 | export const dataEventTypes = { 145 | predictedGender: 'predicted_gender', 146 | predictedAge: 'predicted_age' 147 | } 148 | 149 | export const chartTypes = { 150 | day: 'day', 151 | hour: 'hour', 152 | year: 'year', 153 | }; 154 | 155 | export const dayLabels = [ 156 | 'Monday', 157 | 'Tuesday', 158 | 'Wednesday', 159 | 'Thursday', 160 | 'Friday', 161 | 'Saturday', 162 | 'Sunday', 163 | ]; 164 | 165 | export const emoteRegex = /()/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 |
38 | Please report this by 39 | opening an issue in the Github repository 40 | . 41 |
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 | : 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 |
186 | Please report this by 187 | opening an issue in the Github repository 188 | . 189 |
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 | 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 | 56 | 57 | ) 58 | } 59 |
60 |
61 |
62 | 63 |
64 |
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 | --------------------------------------------------------------------------------