24 | )
25 | },
26 | thematicBreak: () => {
27 | return (
28 |
34 | )
35 | },
36 | }
37 |
38 | export function Markdown({
39 | className,
40 | raw,
41 | }: {
42 | className?: string
43 | raw: string
44 | }) {
45 | return (
46 |
47 | {raw}
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/web/README.md:
--------------------------------------------------------------------------------
1 | # Left on Read: Marketing Site
2 |
3 | The home of our marketing site at [https://leftonread.me/](https://leftonread.me/)
4 |
5 | It is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
6 |
7 | It is deployed via Vercel. To deploy to production, you must log into Vercel manually and click promote to production. It is done manually like this so that deploys are coordinated with when new releases are live.
8 |
9 | ## Local Development
10 |
11 | ### Getting Started
12 |
13 | Install dependencies with yarn:
14 |
15 | ```bash
16 | yarn
17 | ```
18 |
19 | Start the project:
20 |
21 | ```bash
22 | yarn dev
23 | ```
24 |
25 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
26 |
27 | ##### Env Variables
28 |
29 | Note that we load the following env variables via Vercel:
30 |
31 | ```
32 | NEXT_PUBLIC_FIREBASE_API_KEY=
33 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
34 | NEXT_PUBLIC_FIREBASE_PROJECT_ID=
35 | ```
36 |
37 | ### Learn More
38 |
39 | To learn more about Next.js, take a look at the following resources:
40 |
41 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
42 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
43 |
--------------------------------------------------------------------------------
/app/src/analysis/queries/RawMessageQuery.ts:
--------------------------------------------------------------------------------
1 | import * as sqlite3 from 'sqlite3';
2 |
3 | import { allP } from '../../utils/sqliteWrapper';
4 | import { CoreTableNames } from '../tables/types';
5 |
6 | export type RawMessageQueryResult = {
7 | message_id: number;
8 | message: string;
9 | is_from_me: number;
10 | human_readable_date: string;
11 | contact_name: string;
12 | cache_roomnames: string;
13 | phone_number: string;
14 | chat_id: string;
15 | };
16 |
17 | const getCoreQuery = (sortByTime?: boolean) => {
18 | let sortQ = '';
19 | if (sortByTime) {
20 | sortQ = 'ORDER BY chat_id ASC, human_readable_date DESC';
21 | }
22 |
23 | return `
24 | SELECT DISTINCT
25 | message_id,
26 | text AS message,
27 | is_from_me,
28 | human_readable_date,
29 | COALESCE(contact_name, id) as contact_name,
30 | cache_roomnames,
31 | id AS phone_number,
32 | chat_id
33 | FROM ${CoreTableNames.CORE_MAIN_TABLE}
34 | WHERE message_id IS NOT NULL AND chat_id IS NOT NULL
35 | ${sortQ}
36 | `;
37 | };
38 |
39 | export async function getAllMessages(
40 | db: sqlite3.Database,
41 | sortByTime?: boolean
42 | ): Promise {
43 | const q = getCoreQuery(sortByTime);
44 |
45 | return allP(db, q);
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/analysis/queries/TotalSentVsReceivedQuery.ts:
--------------------------------------------------------------------------------
1 | import log from 'electron-log';
2 | import * as sqlite3 from 'sqlite3';
3 |
4 | import * as sqlite3Wrapper from '../../utils/sqliteWrapper';
5 | import { CoreTableNames } from '../tables/types';
6 | import {
7 | getAllFilters,
8 | SharedQueryFilters,
9 | } from './filters/sharedQueryFilters';
10 |
11 | interface TotalSentVsReceivedChartData {
12 | total: number;
13 | is_from_me: number;
14 | }
15 |
16 | export type TotalSentVsReceivedResults = TotalSentVsReceivedChartData[];
17 |
18 | enum TotalSentVsReceivedOutputColumns {
19 | TOTAL = 'total',
20 | IS_FROM_ME = 'is_from_me',
21 | }
22 |
23 | enum TotalSentVsReceivedColumns {
24 | COUNT = 'count',
25 | FRIEND = 'friend',
26 | IS_FROM_ME = 'is_from_me',
27 | TEXT = 'text',
28 | }
29 |
30 | export async function queryTotalSentVsReceived(
31 | db: sqlite3.Database,
32 | filters: SharedQueryFilters
33 | ): Promise {
34 | const allFilters = getAllFilters(filters, undefined, 'contact_name');
35 | const q = `
36 | SELECT COUNT(*) as ${TotalSentVsReceivedOutputColumns.TOTAL},
37 | is_from_me AS ${TotalSentVsReceivedOutputColumns.IS_FROM_ME}
38 | FROM ${CoreTableNames.CORE_MAIN_TABLE}
39 | ${allFilters}
40 | GROUP BY ${TotalSentVsReceivedColumns.IS_FROM_ME}
41 | `;
42 | return sqlite3Wrapper.allP(db, q);
43 | }
44 |
--------------------------------------------------------------------------------
/app/src/analysis/queries/GroupChats/GroupChatActivityOverTimeQuery.ts:
--------------------------------------------------------------------------------
1 | import * as sqlite3 from 'sqlite3';
2 |
3 | import * as sqlite3Wrapper from '../../../utils/sqliteWrapper';
4 | import {
5 | getAllGroupChatTabFilters,
6 | SharedGroupChatTabQueryFilters,
7 | } from '../filters/sharedGroupChatTabFilters';
8 |
9 | export type GroupActivityOverTimeResult = {
10 | count: number;
11 | date: string;
12 | group_chat_name: string;
13 | };
14 |
15 | export async function queryGroupChatActivityOverTime(
16 | db: sqlite3.Database,
17 | filters: SharedGroupChatTabQueryFilters
18 | ): Promise {
19 | const allFilters = getAllGroupChatTabFilters(filters);
20 | const q = `
21 | WITH LOR_TEXTS_OVER_TIME AS (
22 | SELECT
23 | DATE(human_readable_date) as date,
24 | COUNT(*) as count,
25 | group_chat_name
26 | FROM group_chat_core_table
27 | ${allFilters}
28 | GROUP BY DATE(human_readable_date)
29 | )
30 |
31 | SELECT
32 | ct.date as date,
33 | COALESCE(count, 0) as count,
34 | group_chat_name
35 | FROM calendar_table ct
36 | LEFT JOIN LOR_TEXTS_OVER_TIME lt
37 | ON lt.date = ct.date
38 | WHERE ct.date BETWEEN (SELECT MIN(date) FROM LOR_TEXTS_OVER_TIME) AND (SELECT MAX(date) FROM LOR_TEXTS_OVER_TIME)
39 | `;
40 |
41 | return sqlite3Wrapper.allP(db, q);
42 | }
43 |
--------------------------------------------------------------------------------
/app/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@leftonread/eslint-config', 'erb'],
3 | rules: {
4 | // A temporary hack related to IDE not resolving correct package.json
5 | 'import/no-extraneous-dependencies': 'off',
6 | 'import/no-unresolved': 'error',
7 | // Since React 17 and typescript 4.1 you can safely disable the rule
8 | 'react/react-in-jsx-scope': 'off',
9 | 'import/prefer-default-export': 'off',
10 | '@typescript-eslint/ban-ts-comment': 'off',
11 | 'react/require-default-props': 'off',
12 | 'react/state-in-constructor': 'off',
13 | 'react/destructuring-assignment': 'off',
14 | 'no-new': 'off',
15 | '@typescript-eslint/no-explicit-any': 'off',
16 | 'class-methods-use-this': 'off',
17 | },
18 | parserOptions: {
19 | ecmaVersion: 2020,
20 | sourceType: 'module',
21 | project: './tsconfig.json',
22 | tsconfigRootDir: __dirname,
23 | createDefaultProgram: true,
24 | },
25 | settings: {
26 | 'import/resolver': {
27 | // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
28 | node: {},
29 | webpack: {
30 | config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
31 | },
32 | typescript: {},
33 | },
34 | 'import/parsers': {
35 | '@typescript-eslint/parser': ['.ts', '.tsx'],
36 | },
37 | },
38 | };
39 |
--------------------------------------------------------------------------------
/app/src/analysis/queries/GroupChats/GroupChatByFriendsQuery.ts:
--------------------------------------------------------------------------------
1 | import * as sqlite3 from 'sqlite3';
2 |
3 | import * as sqlite3Wrapper from '../../../utils/sqliteWrapper';
4 | import {
5 | getAllGroupChatTabFilters,
6 | SharedGroupChatTabQueryFilters,
7 | } from '../filters/sharedGroupChatTabFilters';
8 |
9 | export type GroupChatByFriends = {
10 | count: number;
11 | contact_name: string;
12 | group_chat_name: string;
13 | };
14 |
15 | export async function queryGroupChatByFriends(
16 | db: sqlite3.Database,
17 | filters: SharedGroupChatTabQueryFilters,
18 | sortMode: 'COUNT' | 'DATE',
19 | limit?: number
20 | ): Promise {
21 | const allFilters = getAllGroupChatTabFilters(filters);
22 | let limitClause = '';
23 | if (typeof limit === 'number') {
24 | limitClause = `LIMIT ${limit}`;
25 | }
26 |
27 | let sortColumn = 'count';
28 | if (sortMode === 'DATE') {
29 | sortColumn = 'human_readable_date';
30 | }
31 |
32 | // TODO(Danilowicz): we probably want to have a sort by mode
33 | // either sort by volume or by recency
34 | const q = `
35 | SELECT COUNT(text) as count, contact_name, group_chat_name
36 | FROM group_chat_core_table
37 | ${allFilters}
38 | GROUP BY contact_name, is_from_me, group_chat_name
39 | ORDER BY ${sortColumn} DESC
40 | ${limitClause}
41 | `;
42 |
43 | return sqlite3Wrapper.allP(db, q);
44 | }
45 |
--------------------------------------------------------------------------------
/web/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, {
2 | DocumentContext,
3 | Head,
4 | Html,
5 | Main,
6 | NextScript,
7 | } from 'next/document'
8 |
9 | import { GA_TRACKING_ID } from '../utils/gtag'
10 |
11 | class MyDocument extends Document {
12 | static async getInitialProps(ctx: DocumentContext) {
13 | const initialProps = await Document.getInitialProps(ctx)
14 | return { ...initialProps }
15 | }
16 |
17 | render() {
18 | return (
19 |
20 |
21 |
25 |
29 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | )
48 | }
49 | }
50 |
51 | export default MyDocument
52 |
--------------------------------------------------------------------------------
/assets/documentation/blog.md:
--------------------------------------------------------------------------------
1 | I built a text message analyzer
2 |
3 | Hi, I built [Left on Read](https://leftonread.me/) — an open-source iMessage analyzer that anyone with a Mac can try for free.
4 |
5 | 
6 |
7 | The data never leaves your computer and everything is [open source](https://github.com/Left-on-Read/leftonread). Left on Read also includes a "your year in texts" experience (simliar to Spotify Wrapped) and productivity tooling, such as response reminders and the ability to schedule messages.
8 |
9 | ## Links
10 |
11 | - [Download link](https://leftonread.me/)
12 | - [Github link](https://github.com/Left-on-Read/leftonread)
13 |
14 | ## Features:
15 |
16 | - top sent word, emoji, contact
17 | - reactions sent in group chats
18 | - filter by a word, friend, or time range
19 | - sentiment analysis
20 | - "Your Year in Text" experience
21 |
22 | ## Technical Details:
23 |
24 | Built with Electron, SQLite, Typescript, React, Charka UI, chartjs
25 |
26 | We copy the ~/Library/Messages/chat.db file on your Mac (this is the same file the Apple iMessage app reads) and then query it with sqlite. We then render graphs off the queries.
27 |
28 | 
29 |
30 | Thanks for reading. Appreciate the support and this community.
31 |
--------------------------------------------------------------------------------
/app/src/analysis/queries/TimeOfDayQuery.ts:
--------------------------------------------------------------------------------
1 | import * as sqlite3 from 'sqlite3';
2 |
3 | import { allP } from '../../utils/sqliteWrapper';
4 | import { CoreMainTableColumns } from '../tables/CoreTable';
5 | import { CoreTableNames } from '../tables/types';
6 | import {
7 | getAllFilters,
8 | SharedQueryFilters,
9 | } from './filters/sharedQueryFilters';
10 |
11 | export type TimeOfDayResults = {
12 | hour: number;
13 | is_from_me: number;
14 | count: number;
15 | }[];
16 |
17 | const getCoreQuery = (allFilters: string) => {
18 | return `
19 | SELECT
20 | strftime('%H', ${CoreMainTableColumns.DATE}) as hour,
21 | is_from_me,
22 | COUNT(*) as count
23 | FROM ${CoreTableNames.CORE_MAIN_TABLE}
24 | ${allFilters}
25 | GROUP BY strftime('%H', ${CoreMainTableColumns.DATE}), is_from_me
26 | `;
27 | };
28 |
29 | export async function queryTimeOfDaySent(
30 | db: sqlite3.Database,
31 | filters: SharedQueryFilters
32 | ): Promise {
33 | const allFilters = getAllFilters(filters, 'is_from_me = 1', 'contact_name');
34 | const q = getCoreQuery(allFilters);
35 |
36 | return allP(db, q);
37 | }
38 |
39 | export async function queryTimeOfDayReceived(
40 | db: sqlite3.Database,
41 | filters: SharedQueryFilters
42 | ): Promise {
43 | const allFilters = getAllFilters(filters, 'is_from_me = 0', 'contact_name');
44 | const q = getCoreQuery(allFilters);
45 |
46 | return allP(db, q);
47 | }
48 |
--------------------------------------------------------------------------------
/app/src/analysis/directories.ts:
--------------------------------------------------------------------------------
1 | export const appDirectoryInitPath = `${process.env.HOME}/.leftonread/init`;
2 | export const appDirectoryPath = `${process.env.HOME}/.leftonread`;
3 | export const addressBookDBName = `AddressBook-v22.abcddb`;
4 | export const addressBookDBAliasName = 'addressBookDB';
5 |
6 | export const addressBookPaths = {
7 | original: `${process.env.HOME}/Library/Application Support/AddressBook`,
8 | app: `${appDirectoryPath}/AddressBookFolder`,
9 | };
10 |
11 | export const chatPaths = {
12 | original: process.env.DEBUG
13 | ? `./src/__tests__/chat.db`
14 | : `${process.env.HOME}/Library/Messages/chat.db`,
15 | app: `${appDirectoryPath}/chat.db`,
16 | };
17 |
18 | export const dirPairings = [addressBookPaths, chatPaths];
19 |
20 | export const addressBookBackUpFolderPath = `${addressBookPaths.app}/Sources`;
21 |
22 | export const appDirectoryLivePath = `${process.env.HOME}/.leftonread/live`;
23 |
24 | export const liveAddressBookPaths = {
25 | original: `${process.env.HOME}/Library/Application Support/AddressBook`,
26 | app: `${appDirectoryLivePath}/AddressBookFolder`,
27 | };
28 |
29 | export const liveChatPaths = {
30 | original: process.env.DEBUG
31 | ? `./src/__tests__/chat.db`
32 | : `${process.env.HOME}/Library/Messages/chat.db`,
33 | app: `${appDirectoryLivePath}/chat.db`,
34 | };
35 |
36 | export const liveDirPairings = [liveAddressBookPaths, liveChatPaths];
37 |
38 | export const liveAddBookBackUpFolderPath = `${liveAddressBookPaths.app}/Sources`;
39 |
--------------------------------------------------------------------------------
/app/src/analysis/tables/CalendarTable.ts:
--------------------------------------------------------------------------------
1 | import log from 'electron-log';
2 |
3 | import * as sqlite3Wrapper from '../../utils/sqliteWrapper';
4 | import { Table, TableNames } from './types';
5 |
6 | // TODO(Danilowicz): We do not need to create from 2000 to 2157, just the min and max of
7 | // human_readable_date on the core main table.
8 | export class CalendarTable extends Table {
9 | async create(): Promise {
10 | await sqlite3Wrapper.runP(
11 | this.db,
12 | `create table ${this.name} (id integer primary key);`
13 | );
14 | await sqlite3Wrapper.runP(
15 | this.db,
16 | `insert into ${this.name} default values;`
17 | );
18 | await sqlite3Wrapper.runP(
19 | this.db,
20 | `insert into ${this.name} default values;`
21 | );
22 | await sqlite3Wrapper.runP(
23 | this.db,
24 | `insert into ${this.name} select null from ${this.name} d1, ${this.name} d2, ${this.name} d3 , ${this.name} d4;`
25 | );
26 | await sqlite3Wrapper.runP(
27 | this.db,
28 | `insert into ${this.name} select null from ${this.name} d1, ${this.name} d2, ${this.name} d3 , ${this.name} d4;`
29 | );
30 | await sqlite3Wrapper.runP(
31 | this.db,
32 | `alter table ${this.name} add date datetime;`
33 | );
34 | await sqlite3Wrapper.runP(
35 | this.db,
36 | `update ${this.name} set date=date('2000-01-01',(-1+id)||' day'); `
37 | );
38 |
39 | log.info(`INFO: created ${this.name}`);
40 | return this.name;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/web/src/theme/colors.ts:
--------------------------------------------------------------------------------
1 | export type Color = {
2 | main: string
3 | hover: string
4 | faded: string
5 | graphFaded: string
6 | }
7 |
8 | const petalPurple: Color = {
9 | main: '#9086D6',
10 | hover: '#A187D7',
11 | faded: 'rgba(144, 134, 214, 0.2)',
12 | graphFaded: 'rgba(144, 134, 214, 0.6)',
13 | }
14 |
15 | const sherwoodGreen: Color = {
16 | main: '#06D6A0',
17 | hover: '#06D6A0',
18 | faded: 'rgba(6, 214, 160, 0.2)',
19 | graphFaded: 'rgba(6, 214, 160, 0.6)',
20 | }
21 |
22 | const skyBlue: Color = {
23 | main: '#54C6EB',
24 | hover: '#54C6EB',
25 | faded: 'rgba(84, 198, 235, 0.2)',
26 | graphFaded: 'rgba(84, 198, 235, 0.6)',
27 | }
28 |
29 | const palePink: Color = {
30 | main: '#E5C1BD',
31 | hover: '#E5C1BD',
32 | faded: 'rgba(229, 193, 189, 0.2)',
33 | graphFaded: 'rgba(229, 193, 189, 0.6)',
34 | }
35 |
36 | const canaryYellow: Color = {
37 | main: '#F5CB5C',
38 | hover: '#F5CB5C',
39 | faded: 'rgba(245, 203, 92, 0.2)',
40 | graphFaded: 'rgba(245, 203, 92, 0.6)',
41 | }
42 |
43 | const frogGreen: Color = {
44 | main: '#7BCDBA',
45 | hover: '#43b59b',
46 | faded: 'rgba(123, 205, 186, 0.2)',
47 | graphFaded: 'rgba(123, 205, 186, 0.8)',
48 | }
49 |
50 | type Palette = {
51 | petalPurple: Color
52 | sherwoodGreen: Color
53 | skyBlue: Color
54 | palePink: Color
55 | canaryYellow: Color
56 | frogGreen: Color
57 | }
58 |
59 | export const palette: Palette = {
60 | petalPurple,
61 | sherwoodGreen,
62 | skyBlue,
63 | palePink,
64 | canaryYellow,
65 | frogGreen,
66 | }
67 |
--------------------------------------------------------------------------------
/app/src/components/Home/Onboarding.tsx:
--------------------------------------------------------------------------------
1 | import { AnimatePresence, motion } from 'framer-motion';
2 | import { useState } from 'react';
3 |
4 | import { GetStarted } from './GetStarted';
5 | import { Permissions } from './Permissions';
6 |
7 | export function Onboarding({ onInitialize }: { onInitialize: () => void }) {
8 | const [step, setStep] = useState(0);
9 |
10 | return (
11 |
12 |
23 |
32 | {step === 0 && (
33 | {
35 | if (hasAccess) {
36 | onInitialize();
37 | } else {
38 | setStep(step + 1);
39 | }
40 | }}
41 | />
42 | )}
43 | {step === 1 && }
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/app/src/utils/db.ts:
--------------------------------------------------------------------------------
1 | import log from 'electron-log';
2 | import * as fs from 'fs';
3 | import * as sqlite3 from 'sqlite3';
4 |
5 | import * as sqlite3Wrapper from './sqliteWrapper';
6 |
7 | export function initializeDB(path: string): sqlite3.Database {
8 | const sqldb = sqlite3.verbose();
9 | const db = new sqldb.Database(path);
10 | return db;
11 | }
12 |
13 | export function closeDB(db: sqlite3.Database) {
14 | log.info('INFO: closing DB');
15 | db.close();
16 | }
17 |
18 | export async function getRecordCounts(
19 | db: sqlite3.Database,
20 | checkQuery: string
21 | ): Promise {
22 | const checkResult = await sqlite3Wrapper.allP(db, checkQuery);
23 | if (checkResult && Number(checkResult[0].count) > 0) {
24 | log.info(`INFO: ${checkResult[0].count} records found`);
25 | return checkResult[0].count;
26 | }
27 | log.info(`INFO: no records found`);
28 | return 0;
29 | }
30 |
31 | export interface DBWithRecordCount {
32 | db: sqlite3.Database | null;
33 | recordCount: number;
34 | }
35 |
36 | export async function getDBWithRecordCounts(
37 | dbPath: string,
38 | checkQuery: string
39 | ) {
40 | log.info(`INFO: attempting to initialize ${dbPath}`);
41 | if (fs.existsSync(dbPath)) {
42 | const db = initializeDB(dbPath);
43 | const recordCount = await getRecordCounts(db, checkQuery);
44 | if (recordCount < 1) {
45 | closeDB(db); // we close here because if it's empty it's useless to us
46 | }
47 | return { db, recordCount };
48 | }
49 | return { db: null, recordCount: 0 };
50 | }
51 |
--------------------------------------------------------------------------------
/app/.erb/configs/webpack.config.base.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Base webpack config used across other specific configs
3 | */
4 |
5 | import webpack from 'webpack';
6 |
7 | import { dependencies as externals } from '../../release/app/package.json';
8 | import webpackPaths from './webpack.paths';
9 |
10 | const PalettePlugin = require('@palette.dev/webpack-plugin');
11 |
12 | const configuration: webpack.Configuration = {
13 | externals: [...Object.keys(externals || {})],
14 |
15 | stats: 'errors-only',
16 |
17 | module: {
18 | rules: [
19 | {
20 | test: /\.[jt]sx?$/,
21 | exclude: /node_modules/,
22 | use: {
23 | loader: 'ts-loader',
24 | options: {
25 | // Remove this line to enable type checking in webpack builds
26 | transpileOnly: true,
27 | },
28 | },
29 | },
30 | ],
31 | },
32 |
33 | output: {
34 | path: webpackPaths.srcPath,
35 | // https://github.com/webpack/webpack/issues/1114
36 | library: {
37 | type: 'commonjs2',
38 | },
39 | },
40 |
41 | /**
42 | * Determine the array of extensions that should be used to resolve modules.
43 | */
44 | resolve: {
45 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
46 | modules: [webpackPaths.srcPath, 'node_modules'],
47 | },
48 |
49 | plugins: [
50 | new webpack.EnvironmentPlugin({
51 | NODE_ENV: 'production',
52 | }),
53 | new PalettePlugin({
54 | key: process.env.PALETTE_ASSET_KEY ?? 'foobar',
55 | }),
56 | ],
57 | };
58 |
59 | export default configuration;
60 |
--------------------------------------------------------------------------------
/app/src/components/Dashboard/Wrapped/AnimationRunner.ts:
--------------------------------------------------------------------------------
1 | export class AnimationRunner {
2 | counter: number = 0;
3 |
4 | isActive: boolean = false;
5 |
6 | events: Map void> = new Map();
7 |
8 | durationInSecs: number = 10;
9 |
10 | tickListener?: (arg0: number) => void = () => {};
11 |
12 | existingTimeout?: NodeJS.Timeout;
13 |
14 | constructor(durationInSecs: number, tickListener?: (arg0: number) => void) {
15 | this.tickListener = tickListener;
16 | this.counter = 0;
17 | this.events = new Map();
18 | this.durationInSecs = durationInSecs;
19 | }
20 |
21 | start() {
22 | if (this.existingTimeout) {
23 | clearTimeout(this.existingTimeout);
24 | }
25 | this.isActive = true;
26 | this.tick();
27 | }
28 |
29 | pause() {
30 | this.isActive = false;
31 | }
32 |
33 | addEvent(time: number, callback: () => void) {
34 | this.events.set(time, callback);
35 | }
36 |
37 | reset() {
38 | this.counter = 0;
39 | }
40 |
41 | tick() {
42 | this.existingTimeout = setTimeout(() => {
43 | if (this.counter >= this.durationInSecs * 1000) {
44 | this.isActive = false;
45 | }
46 |
47 | if (this.isActive) {
48 | this.counter += 100;
49 |
50 | const possibleEvent = this.events.get(this.counter);
51 | if (possibleEvent) {
52 | possibleEvent();
53 | }
54 |
55 | if (this.counter % 1000 === 0 && this.tickListener) {
56 | this.tickListener(this.counter);
57 | }
58 |
59 | this.tick();
60 | }
61 | }, 100);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/.github/workflows/app-publish.yml:
--------------------------------------------------------------------------------
1 | name: App - Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | defaults:
9 | run:
10 | working-directory: ./app/
11 |
12 | jobs:
13 | publish:
14 | # To enable auto publishing to github, update your electron publisher
15 | # config in package.json > "build" and remove the conditional below
16 | # if: ${{ github.repository_owner == 'electron-react-boilerplate' }}
17 |
18 | runs-on: ${{ matrix.os }}
19 |
20 | strategy:
21 | matrix:
22 | os: [macos-latest]
23 |
24 | steps:
25 | - name: Checkout git repo
26 | uses: actions/checkout@v1
27 |
28 | - name: Install Node and NPM
29 | uses: actions/setup-node@v1
30 | with:
31 | node-version: 16
32 | cache: npm
33 |
34 | - name: Install dependencies
35 | run: |
36 | yarn install
37 | env:
38 | PALETTE_ASSET_KEY: ${{ secrets.PALETTE_ASSET_KEY }}
39 |
40 | - name: Publish releases
41 | env:
42 | # These values are used for auto updates signing
43 | APPLE_ID: ${{ secrets.APPLE_ID }}
44 | APPLE_ID_PASS: ${{ secrets.APPLE_ID_PASS }}
45 | CSC_LINK: ${{ secrets.CSC_LINK }}
46 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
47 | # This is used for uploading release assets to github
48 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49 | PALETTE_ASSET_KEY: ${{ secrets.PALETTE_ASSET_KEY }}
50 | run: |
51 | yarn run postinstall
52 | yarn run build
53 | yarn exec electron-builder -- --publish always --mac
54 |
--------------------------------------------------------------------------------
/app/src/analysis/tables/GroupChatCoreTable.ts:
--------------------------------------------------------------------------------
1 | import log from 'electron-log';
2 |
3 | import * as sqlite3Wrapper from '../../utils/sqliteWrapper';
4 | import { Table, TableNames } from './types';
5 |
6 | export class GroupChatCoreTable extends Table {
7 | async create(): Promise {
8 | const q = `
9 | CREATE TABLE IF NOT EXISTS ${this.name} AS
10 |
11 | WITH GROUP_CHAT_NAMES AS (select
12 | group_concat(distinct coalesced_contact_name) as participants,
13 | display_name,
14 | cmj.chat_id
15 | from
16 | chat c
17 | join chat_message_join cmj on cmj.chat_id = c."ROWID"
18 | join core_main_table m on m. "ROWID" = cmj.message_id
19 | group by
20 | c."ROWID"
21 | having
22 | count(distinct coalesced_contact_name) > 1),
23 |
24 | GC_CORE_TABLE AS (SELECT
25 | text,
26 | display_name,
27 | human_readable_date,
28 | coalesce(coalesced_contact_name, "you") as contact_name,
29 | participants, is_from_me,
30 | associated_message_type,
31 | CASE WHEN display_name = "" THEN participants ELSE display_name END as group_chat_name ,
32 | REPLACE(REPLACE(associated_message_guid, "p:0/", ""), "p:1/", "")as associated_guid
33 | FROM core_main_table cm
34 | JOIN GROUP_CHAT_NAMES gcm
35 | on cm.chat_id = gcm.chat_id)
36 |
37 | SELECT * FROM GC_CORE_TABLE WHERE group_chat_name IS NOT NULL AND contact_name IS NOT NULL
38 | `;
39 |
40 | await sqlite3Wrapper.runP(this.db, q);
41 | log.info(`INFO: created ${this.name}`);
42 | return this.name;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/src/components/ComingSoon.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Text, useDisclosure } from '@chakra-ui/react';
2 |
3 | import Coworkers from '../../assets/illustrations/coworkers.svg';
4 | import { Float } from './Float';
5 | import { EmailModal } from './Support/EmailModal';
6 |
7 | export function ComingSoon() {
8 | const {
9 | isOpen: isEmailModalOpen,
10 | onOpen: onEmailModalOpen,
11 | onClose: onEmailModalClose,
12 | } = useDisclosure();
13 |
14 | return (
15 |
16 |
22 |
31 |
32 |
33 |
34 | Thanks for your support!
35 |
36 |
37 | We have exciting features planned: powerful filtering and support for
38 | Facebook Messenger 👀
39 |
40 |
41 |
50 |
4 |
5 | iMessage Wrapped! Left on Read is built with Electron, React, SQLite, Typescript.
6 |
7 | Your texting data never leaves your computer. We are proudly open-source for this reason. The app works without an Internet connection.
8 |
9 | ## Features:
10 |
11 | - 🔐 code runs locally, no data leaves your computer
12 | - 📊 top sent and received word, emoji, contact
13 | - 🍆 your texting activity
14 | - 😂 reactions sent in group chats
15 | - 🔍 filter by a word, friend, or time range
16 | - 💯 sentiment analysis
17 | - 🎁 "Your Year in Text" experience a.k.a iMessage Wrapped
18 |
19 | ## Download Left on Read for Mac
20 |
21 |