Belated Initial Commit

master
Auri 2021-08-26 01:30:47 -07:00
commit 01760496ed
63 changed files with 13052 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
build/

168
client/.eslintrc.js Executable file
View File

@ -0,0 +1,168 @@
/*
👋 Hi! This file was autogenerated by tslint-to-eslint-config.
https://github.com/typescript-eslint/tslint-to-eslint-config
It represents the closest reasonable ESLint configuration to this
project's original TSLint configuration.
We recommend eventually switching this configuration to extend from
the recommended rulesets in typescript-eslint.
https://github.com/typescript-eslint/tslint-to-eslint-config/blob/master/docs/FAQs.md
Happy linting! 💖
*/
module.exports = {
"root": true,
"env": {
"browser": true
},
"extends": [
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"eslint-plugin-jsdoc"
],
"rules": {
"@typescript-eslint/dot-notation": "error",
"@typescript-eslint/indent": [
"error",
"tab",
{
"CallExpression": {
"arguments": 1
},
"FunctionDeclaration": {
"parameters": 1
},
"FunctionExpression": {
"parameters": 1
}
}
],
"@typescript-eslint/member-delimiter-style": [
"error",
{
"multiline": {
"delimiter": "semi",
"requireLast": true
},
"singleline": {
"delimiter": "semi",
"requireLast": false
}
}
],
"@typescript-eslint/member-ordering": "error",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-unused-expressions": "error",
"@typescript-eslint/no-use-before-define": "error",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/quotes": [
"error",
"single"
],
"@typescript-eslint/semi": [
"error",
"always"
],
"@typescript-eslint/type-annotation-spacing": "error",
"brace-style": [
"error",
"stroustrup",
{ "allowSingleLine": true }
],
"comma-dangle": "error",
"curly": "off",
"default-case": "error",
"eol-last": "error",
"eqeqeq": [
"error",
"smart"
],
"guard-for-in": "error",
"id-blacklist": [
"error",
"any",
"Number",
"number",
"String",
"string",
"Boolean",
"boolean",
"Undefined",
"undefined"
],
"id-match": "error",
"jsdoc/check-alignment": "error",
"jsdoc/check-indentation": "error",
"jsdoc/newline-after-description": "error",
"max-len": [
"error", {
"code": 150
}
],
"no-bitwise": "error",
"no-caller": "error",
"no-console": [
"error",
{
"allow": [
"log",
"dirxml",
"warn",
"error",
"dir",
"timeLog",
"assert",
"clear",
"count",
"countReset",
"group",
"groupCollapsed",
"groupEnd",
"table",
"Console",
"markTimeline",
"profile",
"profileEnd",
"timeline",
"timelineEnd",
"timeStamp",
"context"
]
}
],
"no-debugger": "error",
"no-empty": "error",
"no-eval": "error",
"no-fallthrough": "error",
"no-new-wrappers": "error",
"no-redeclare": "error",
"no-trailing-spaces": [
"error", {
"skipBlankLines": true
}
],
"no-unused-labels": "error",
"no-var": "error",
"radix": "error",
"spaced-comment": [
"error",
"always",
{
"markers": [
"/"
]
}
]
}
};

1
client/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
pw.txt

5863
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

69
client/package.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "@aether/client",
"version": "0.0.1",
"private": true,
"description": "Web Frontend / Electron Renderer for the Aether Mail software.",
"main": "build/Main.js",
"scripts": {
"dev": "webpack --watch --progress --config webpack.ts"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Aurailus/Aether.git"
},
"keywords": [
"mail",
"email",
"cloud",
"aether"
],
"author": "Auri Collings",
"license": "UNLICENSED",
"bugs": {
"url": "https://github.com/Aurailus/Aether/issues"
},
"homepage": "https://github.com/Aurailus/Aether#readme",
"devDependencies": {
"@babel/core": "^7.14.3",
"@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.2",
"@babel/plugin-transform-react-jsx": "^7.14.3",
"@babel/preset-env": "^7.14.4",
"@babel/preset-typescript": "^7.13.0",
"@types/webpack": "^5.28.0",
"@types/webpack-merge": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^4.26.1",
"@typescript-eslint/parser": "^4.26.1",
"autoprefixer": "^10.2.6",
"babel-loader": "^8.2.2",
"css-loader": "^5.2.6",
"css-minimizer-webpack-plugin": "^3.0.2",
"eslint": "^7.28.0",
"eslint-plugin-jsdoc": "^35.1.3",
"fork-ts-checker-webpack-plugin": "^6.2.10",
"mini-css-extract-plugin": "^2.2.0",
"postcss-extend": "^1.0.5",
"postcss-import": "^14.0.2",
"postcss-loader": "^5.3.0",
"postcss-nested": "^5.0.5",
"postcss-nested-vars": "^1.0.0",
"sugarss": "^3.0.3",
"tailwindcss": "^2.2.7",
"tailwindcss-interaction-variants": "^5.0.0",
"ts-node": "^10.0.0",
"typescript": "^4.3.2",
"webpack": "^5.38.1",
"webpack-cli": "^4.7.2",
"webpack-livereload-plugin": "^3.0.1",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"common": "file:../common/build",
"@tailwindcss/typography": "^0.4.1",
"dayjs": "^1.10.6",
"preact": "^10.5.13",
"react-router-dom": "^5.2.0",
"tslib": "^2.2.0",
"vibin-hooks": "^0.2.0"
}
}

11
client/postcss.config.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
parser: "sugarss",
plugins: [
require('tailwindcss'),
require('autoprefixer'),
require('postcss-nested'),
require('postcss-nested-vars'),
require('postcss-extend'),
require('postcss-import')
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
client/res/icon/chat.svg Executable file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#829ab1"><rect width="16" height="13" x="2" y="2" fill="#486581" rx="2"/><path class="primary" d="M6 16V8c0-1.1.9-2 2-2h12a2 2 0 0 1 2 2v13a1 1 0 0 1-1.7.7L16.58 18H8a2 2 0 0 1-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 257 B

62
client/res/icon/email.svg Normal file
View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="24"
height="24"
viewBox="0 0 24 24"
id="svg4"
sodipodi:docname="email.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview6"
showgrid="false"
inkscape:zoom="45.254834"
inkscape:cx="12.721343"
inkscape:cy="14.131672"
inkscape:window-x="3840"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
style="fill:#aabccf;fill-opacity:1"
d="M 12 2.2421875 L 21.029297 7.8925781 C 21.164309 7.9740509 21.286539 8.0740472 21.398438 8.1835938 C 21.28785 8.0719968 21.164869 7.9724354 21.029297 7.890625 L 12 2.2421875 z M 12 2.2421875 L 2.9707031 7.890625 C 2.8419323 7.9683315 2.7250009 8.0628924 2.6191406 8.1679688 C 2.7266608 8.0651234 2.8424345 7.9699816 2.9707031 7.8925781 L 12 2.2421875 z M 21.398438 8.1835938 C 21.48589 8.2718445 21.559919 8.3720661 21.630859 8.4746094 C 21.559356 8.3723214 21.48742 8.2707063 21.398438 8.1835938 z M 21.630859 8.4746094 C 21.6447 8.494409 21.662652 8.5109328 21.675781 8.53125 L 21.677734 8.53125 C 21.664393 8.5105418 21.644862 8.49485 21.630859 8.4746094 z M 21.675781 8.53125 L 19.974609 9.5859375 L 20 9.6015625 L 12 14.601562 L 4 9.6015625 L 4.046875 9.5722656 L 2.3378906 8.5136719 C 2.1302396 8.8272351 2 9.1947189 2 9.6015625 L 2 19.601562 A 2 2 0 0 0 4 21.601562 L 20 21.601562 A 2 2 0 0 0 22 19.601562 L 22 9.6015625 C 22 9.2027773 21.875947 8.8410065 21.675781 8.53125 z M 2.3378906 8.5136719 C 2.3464029 8.500818 2.3564338 8.4892052 2.3652344 8.4765625 C 2.3562391 8.4896024 2.34467 8.5004458 2.3359375 8.5136719 L 2.3378906 8.5136719 z M 2.3652344 8.4765625 C 2.4402308 8.3678453 2.5221249 8.2648001 2.6152344 8.171875 C 2.5202101 8.2632621 2.440722 8.3681198 2.3652344 8.4765625 z M 14.900391 16.28125 C 9.9335936 19.388021 4.9667966 22.494791 14.900391 16.28125 z "
id="path2" />
<g
id="g851" />
<path
style="fill:#eaf0f6;fill-opacity:1"
d="m 12.000775,2.2586946 -9.0292975,5.648438 c -0.257542,0.155413 -0.469659,0.372978 -0.634766,0.623046 l 1.712891,1.0585965 7.9511725,-4.9707055 7.974609,4.9843776 1.703125,-1.0546896 C 21.51211,8.2894716 21.294016,8.0664086 21.030072,7.9071326 Z m 9.677734,6.289063 c -14.4518235,11.3802134 -7.225911,5.6901094 0,0 z M 2.3367115,8.5301786 c -1.5572919,11.3919324 -0.778646,5.6959694 0,0 z M 14.901166,16.297763 c -9.9335945,6.213541 -4.9667963,3.106771 0,0 z M 4.0496025,9.5887751 c -2.699219,10.6862009 -1.34961,5.3431019 0,0 z m 15.9257815,0.01367 c -13.3164065,10.6770859 -6.658203,5.3385449 0,0 z"
id="path2-3-3"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccccccccc" />
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 24 24"
class="icon-home"
version="1.1"
id="svg6"
sodipodi:docname="home-account.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview8"
showgrid="false"
inkscape:zoom="9.8333333"
inkscape:cx="-5.1864405"
inkscape:cy="5.1864407"
inkscape:window-x="1920"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<path
d="M 10.189718,17.525437 H 7.7760903 A 0.60340698,0.60340698 0 0 1 7.1726832,16.92203 v -6.034068 l 4.8272558,-4.8272565 4.827256,4.8272565 v 6.034068 a 0.60340698,0.60340698 0 0 1 -0.603408,0.603407 H 13.810159 A 0.60340698,0.60340698 0 0 1 13.206753,16.92203 v -2.413627 a 0.60340698,0.60340698 0 0 0 -0.603407,-0.603407 h -1.206814 a 0.60340698,0.60340698 0 0 0 -0.603406,0.603407 v 2.413627 a 0.60340698,0.60340698 0 0 1 -0.603408,0.603407 z m 1.810221,-5.430661 a 1.2068144,1.2068144 0 1 0 0,-2.4136288 1.2068144,1.2068144 0 0 0 0,2.4136288 z"
id="path2"
style="fill:#aabccf;fill-opacity:0.94117647;stroke-width:0.60340697"
inkscape:connector-curvature="0" />
<path
d="M 12.005973,6.9175428 6.9976953,11.925821 A 0.60374667,0.60374667 0 1 1 6.1408575,11.075016 L 11.583588,5.6322862 a 0.60340698,0.60340698 0 0 1 0.850804,0 l 5.424629,5.4427298 a 0.60374674,0.60374674 0 0 1 -0.856838,0.850805 z"
id="path4"
style="fill:#74879d;fill-opacity:1;stroke-width:0.60340697"
inkscape:connector-curvature="0" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
client/res/icon/home.svg Executable file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="icon-home"><path fill="#829ab1" d="M9 22H5a1 1 0 0 1-1-1V11l8-8 8 8v10a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-4a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v4a1 1 0 0 1-1 1zm3-9a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/><path fill="#486581" d="M12.01 4.42l-8.3 8.3a1 1 0 1 1-1.42-1.41l9.02-9.02a1 1 0 0 1 1.41 0l8.99 9.02a1 1 0 0 1-1.42 1.41l-8.28-8.3z"/></svg>

After

Width:  |  Height:  |  Size: 397 B

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg8"
version="1.1"
viewBox="0 0 6.3499999 6.3500002"
height="24"
width="24">
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="translate(0,-290.64998)"
id="layer1">
<path
id="rect815"
d="M 0.01033529,297.04958 H 6.3582683 v -4.47673 a 1.3484752,1.3484752 0 0 1 -1.0841718,0.54881 1.3484752,1.3484752 0 0 1 -1.348238,-1.34824 1.3484752,1.3484752 0 0 1 0.5958292,-1.11776 H 0.01033529 Z m 4.51135241,-6.39392 h 1.5063682 a 1.3484752,1.3484752 0 0 0 -0.7539594,-0.23048 1.3484752,1.3484752 0 0 0 -0.7524088,0.23048 z m 1.5063682,0 a 1.3484752,1.3484752 0 0 1 0.3302124,0.31832 v -0.31832 z m 0.3302124,0.31832 v 1.59887 a 1.3484752,1.3484752 0 0 0 0.2645833,-0.79943 1.3484752,1.3484752 0 0 0 -0.2645833,-0.79944 z"
style="opacity:1;fill:#7c7c7c;fill-opacity:1;stroke:none;stroke-width:0.0987898;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
client/res/user-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

BIN
client/res/user-work.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -0,0 +1,43 @@
import { h, Fragment } from 'preact';
import { ID, AccountMeta } from 'common/graph/type';
import AccountSidebarItem from './AccountSidebarItem';
interface Props {
accounts: AccountMeta[];
active: ID;
onClick: (id: ID) => void;
}
/**
* Displays a list of account buttons stacked vertically,
* indicating if they have unread messages, and the active account.
* Shows a home account if there are two or more accounts registered.
*/
export default function AccountSidebar({ accounts, active, onClick }: Props) {
return (
<div class='w-18 h-full p-3 bg-gray-50 flex flex-col gap-3'>
{accounts.length > 1 && <Fragment>
<AccountSidebarItem
active={active === ''}
onClick={() => onClick('')}
account={{
unread: true, id: '', name: 'Home',
image: '../../client/res/icon/home-account.svg',
address: `${accounts.length} Account${accounts.length === 1 ? '' : 's'}`
}}/>
<hr class='border-b-2 rounded-full w-3/4 mx-auto'/>
</Fragment>}
{accounts.map(account => <AccountSidebarItem
account={account}
active={active === account.id}
onClick={() => onClick(account.id)}
/>)}
</div>
);
}

View File

@ -0,0 +1,38 @@
import { h } from 'preact';
import { mergeClasses } from './Util';
import { AccountMeta } from 'common/graph/type';
interface Props {
account: AccountMeta;
active: boolean;
onClick: () => void;
}
/**
* An image button representing an account in the account sidebar.
*/
export default function AccountSidebarItem({ account, active, onClick }: Props) {
return (
<button key={account.id} onClick={onClick} class='w-12 h-12 group relative !outline-none'>
<img src={account.image} alt='' role='presentation'
style={account.unread ? {
'-webkit-mask-image': 'url(../../client/res/mask/unread-mask.svg)', '-webkit-mask-size': '100%' } : {}}
class={mergeClasses('interact-none transition-all ease-out bg-gray-100 group-hover:bg-gray-200',
'group-active:duration-300 group-active:ease-bounce group-active:!rounded-[0.75rem]',
active ? 'rounded-[0.75rem] bg-gray-200' : 'rounded-[1.5rem] group-hover:rounded-[1.10rem]')} />
<p class='absolute interact-none px-2.5 py-2 left-full rounded shadow-md -top-0.5 bg-black
text-left whitespace-nowrap transition-all duration-75 transform translate-x-3 scale-90 opacity-0
group-hover:scale-100 group-hover:opacity-100 group-hover:translate-x-5'>
<span class='block font-medium leading-none text-gray-800 mb-1'>{account.name}</span>
<span class='block text-sm leading-none text-gray-700'>{account.address}</span>
<div class='absolute w-0 h-0 border-8 border-black border-t-transparent
border-r-transparent top-4 -left-px transform rotate-45'/>
</p>
{account.unread && <div class='absolute w-3 h-3 left-8 bottom-8 m-0.5 rounded-full bg-blue-300'>
<div class='w-3 h-3 rounded-full bg-blue-300 animate-ping'/>
</div>}
</button>
);
}

73
client/src/App.tsx Normal file
View File

@ -0,0 +1,73 @@
import { h, Fragment } from 'preact';
import { useAsyncMemo } from 'vibin-hooks';
import { useState, useLayoutEffect, useMemo } from 'preact/hooks';
import { query, QUERY_ACCOUNT, QUERY_ACCOUNTS } from './Graph';
import { ID, Contact, Conversation, AccountMeta } from 'common/graph/type';
import AccountSidebar from './AccountSidebar';
import ConversationView from './ConversationView';
import ConversationsSidebar from './ConversationsSidebar';
export default function App() {
const [ activeAccount, setActiveAccount ] = useState<ID>('');
const [ contacts, setContacts ] = useState<Record<ID, Contact[]>>({});
const [ conversations, setConversations ] = useState<Record<ID, Conversation[]>>({});
const [ activeConversation, setActiveConversation ] = useState<ID>('');
const accounts = useAsyncMemo<AccountMeta[] | undefined>(async () => {
let accounts = (await query(QUERY_ACCOUNTS)).accounts!;
setActiveAccount(accounts[0]?.id ?? '');
return accounts;
}, []);
const account = useMemo(() => (accounts ?? []).filter(a => a.id === activeAccount)[0],
[ activeAccount, accounts === undefined ]);
const conversation = useMemo(() => conversations[account?.id ?? '']?.filter(m => m.id === activeConversation)[0],
[ activeConversation, account?.id ]);
useLayoutEffect(() => {
if (!account) return;
query(QUERY_ACCOUNT, { account: account.id }).then(({ account }) => {
if (!account) throw 'Missing account!';
setContacts({ ...contacts, [account.id]: account.contacts });
setConversations({ ...conversations, [account.id]: account.conversations });
setActiveConversation(account.conversations[account.conversations.length - 1].id);
});
}, [ account ]);
const handleSelectAccount = (id: ID) => {
setActiveAccount(id);
setActiveConversation(conversations[id] ? conversations[id][conversations[id].length - 1].id : '');
};
const handleSelectConversation = (id: ID) => {
setActiveConversation(id);
};
return (
<div class='flex w-screen h-screen bg-gray-200 text-gray-900'>
<h1 class='sr-only'>Aether</h1>
<AccountSidebar accounts={accounts ?? []} active={activeAccount} onClick={handleSelectAccount}/>
{account && <Fragment>
<div class='w-72 bg-gray-100 flex flex-col'>
<div class='h-14 flex-shrink-0 border-b border-gray-50/75 shadow-sm flex items-center px-4 pl-3'>
<img class='px-1 mr-3 interact-none' src='../../client/res/icon/email.svg' width={32} height={32}/>
<h2 class='text-gray-900 font-medium truncate pt-0.5 pl-0.5'>{account.name}</h2>
</div>
<ConversationsSidebar conversations={conversations[account.id] ?? []} contacts={contacts[account.id] ?? []}
active={activeConversation} onClick={handleSelectConversation}/>
</div>
<div class='flex-grow flex flex-col h-full overflow-hidden'>
<div class='h-14 flex-shrink-0 border-b border-gray-50/50 shadow-sm flex items-center px-4'>
<img class='px-1 mr-3 interact-none' src='../../client/res/icon/chat.svg' width={32} height={32}/>
<h3 class='text-gray-900 font-medium truncate pt-0.5 pl-0.5'>{conversation && conversation.title}</h3>
</div>
{conversation && <ConversationView accountId={account.id}
contacts={contacts[account.id]} conversation={conversation}/>}
</div>
</Fragment>}
</div>
);
}

View File

@ -0,0 +1,29 @@
import { h } from 'preact';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
// import { mergeClasses } from './Util';
import { Message, Contact } from 'common/graph/type';
interface Props {
message: Message;
contacts: Contact[];
}
export default function ConversationMessage({ message, contacts }: Props) {
return (
<li class='flex gap-3 w-full last-of-type:pb-4'>
<div class={'w-11 h-11 p-2.5 bg-gray-300/75 rounded-full flex-shrink-0 interact-none'}>
<img class='w-6 h-6' src='../../client/res/icon/chat.svg' width={32} height={32} alt='' role='presentation'/>
</div>
<div class='flex flex-col pt-0.5 w-full'>
<p class='leading-none'>
<span class='font-medium'>{contacts.filter(contact =>
contact.addresses.includes(message.from))[0]?.name ?? message.from}</span>
<span class='text-sm text-gray-600 pl-1.5'>{dayjs(message.date).fromNow()}</span></p>
<div class='text-gray-800 prose pt-1 max-w-none' dangerouslySetInnerHTML={{ __html: message.markdown }}/>
</div>
</li>
);
}

View File

@ -0,0 +1,71 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useAsyncMemo } from 'vibin-hooks';
import { query, QUERY_MESSAGES } from './Graph';
import { Message, Contact, Conversation } from 'common/graph/type';
import ConversationMessage from './ConversationMessage';
import { mergeClasses } from './Util';
interface Props {
accountId: string;
contacts: Contact[];
conversation: Conversation;
}
export default function ConversationView({ accountId, conversation, contacts }: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const [ bottom, setBottom ] = useState<boolean>(false);
useEffect(() => {
let elem = scrollRef.current!;
let scrolled = true;
const callback = () => scrolled = true;
elem.addEventListener('scroll', callback);
window.addEventListener('resize', callback);
const test = () => {
if (!scrolled) return;
setBottom(elem.scrollHeight <= elem.offsetHeight ||
elem.scrollTop + elem.offsetHeight >= elem.scrollHeight);
scrolled = false;
};
test();
let interval = window.setInterval(test, 100);
return () => {
elem.removeEventListener('scroll', callback);
window.removeEventListener('resize', callback);
window.clearInterval(interval);
};
}, []);
const messages = useAsyncMemo<Message[]>(async () => {
let res = (await query(QUERY_MESSAGES, { account: accountId, ids: conversation.messages })).messages!;
window.requestAnimationFrame(() => scrollRef.current!.scrollTo({ top: scrollRef.current!.scrollHeight }));
return res;
}, [ conversation.lastMessage, accountId ]);
return (
<div class='flex flex-col overflow-hidden h-full scrollbar-200'>
<div class='flex-grow overflow-auto' ref={scrollRef}>
<ol class='flex flex-col justify-end gap-6 flex-grow p-3 min-h-full'>
{(messages ?? []).map(message => <ConversationMessage contacts={contacts} message={message}/>)}
</ol>
</div>
<div
style={!bottom ? { boxShadow: '0 0 16px 0 rgba(0, 0, 0, 0.15), 0 0 4px 0 rgba(0, 0, 0, 0.2)'} : {}}
class={mergeClasses('relative p-2.5 flex flex-shrink-0 gap-2 z-10 transition-all border-t',
!bottom && ' border-gray-100')}>
<div class='w-10 h-10 bg-gray-100 rounded-full'/>
<div class='w-10 h-10 bg-gray-100 rounded-full'/>
<div class='flex-grow h-10 bg-gray-100 rounded-full flex items-center pl-4'>
<p class='text-gray-500'>Send a message...</p>
</div>
<div class='w-10 h-10 bg-gray-100 rounded-full'/>
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { h } from 'preact';
import { ID, Conversation, Contact } from 'common/graph/type';
import ConversationSidebarItem from './ConversationsSidebarItem';
interface Props {
contacts: Contact[];
conversations: Conversation[];
active: ID;
onClick: (id: ID) => void;
}
export default function ConversationsSidebar(props: Props) {
return (
<ul class='flex flex-col p-1 gap-y-1 pr-0 overflow-y-scroll'>
{props.conversations.sort((a, b) => +b.lastMessage - +a.lastMessage).map(conv =>
<ConversationSidebarItem conversation={conv} contacts={props.contacts}
active={props.active === conv.id} onClick={() => props.onClick(conv.id)} />)}
</ul>
);
}

View File

@ -0,0 +1,36 @@
import { h } from 'preact';
import { mergeClasses } from './Util';
import { Contact, Conversation } from 'common/graph/type';
interface Props {
contacts: Contact[];
conversation: Conversation;
active: boolean;
onClick: () => void;
}
export default function ConversationItem({ conversation: conv, contacts, active, onClick }: Props) {
return (
<li key={conv.id}>
<button onClick={onClick}
class={mergeClasses(
'w-full flex items-center gap-2.5 text-left px-1.5 py-1.5 rounded focus:outline-none',
active ? 'bg-gray-300/50' : 'hover:bg-gray-200')}>
<div class={mergeClasses(
'w-9 h-9 p-2 bg-gray-300/75 rounded-full flex-shrink-0 interact-none',
active && 'bg-gray-300')}>
<img class='w-5 h-5' src='../../client/res/icon/chat.svg' width={32} height={32} alt='' role='presentation'/>
</div>
<div class='overflow-hidden flex flex-col justify-center'>
<p class={mergeClasses('text-sm font-medium truncate', active ? 'text-gray-900' : 'text-gray-600')}>
{conv.title}</p>
<p class={mergeClasses('text-xs font-medium truncate', active ? 'text-gray-800' : 'text-gray-400')}>
{conv.participants.map(address => contacts.filter(contact =>
contact.addresses.includes(address))[0]?.name ?? address).join(', ')}</p>
</div>
</button>
</li>
);
}

13
client/src/Graph.ts Normal file
View File

@ -0,0 +1,13 @@
import { Query, Type } from 'common/graph';
// @ts-ignore
const rawQuery = window.aether.query;
export function query(query: string, data: any = {}): Promise<Partial<Type.Root>> {
return rawQuery(query, data);
}
export const QUERY_ACCOUNTS = `{ accounts ${Query.AccountMeta} }`;
export const QUERY_ACCOUNT = `query($account: String!) { account(account: $account) ${Query.Account} }`;
export const QUERY_MESSAGES = `query($account: String!, $ids: [String!]!) {
messages(account: $account, ids: $ids) ${Query.Message} }`;

7
client/src/Main.ts Normal file
View File

@ -0,0 +1,7 @@
import { render, h } from 'preact';
import App from './App';
import './Tailwind.tw';
render(h(App, null), document.body);

71
client/src/Tailwind.tw Executable file
View File

@ -0,0 +1,71 @@
@import 'tailwindcss/base'
@import 'tailwindcss/components'
@import 'tailwindcss/utilities'
@font-face
font-family: 'Roboto'
font-style: normal
font-weight: 400
font-display: swap
src: url('../../client/res/font/Roboto-400.ttf') format('truetype')
@font-face
font-family: 'Roboto'
font-style: italic
font-weight: 400
font-display: swap
src: url('../../client/res/font/Roboto-400i.ttf') format('truetype')
@font-face
font-family: 'Roboto'
font-style: normal
font-weight: 500
font-display: swap
src: url('../../client/res/font/Roboto-500.ttf') format('truetype')
@font-face
font-family: 'Roboto'
font-style: italic
font-weight: 500
font-display: swap
src: url('../../client/res/font/Roboto-500i.ttf') format('truetype')
@layer utilities
.interact-none
user-select: none
pointer-events: none
.scrollbar-200
*::-webkit-scrollbar
background-color: theme(colors.gray.200)
*::-webkit-scrollbar-thumb
background-color: theme(colors.gray.400)
border: 4px solid theme(colors.gray.200)
&:hover
background-color: theme(colors.gray.500)
html
-webkit-tap-highlight-color: rgba(255, 255, 255, 0)
*::selection
background-color: #74879D66
strong
@apply font-medium
*::-webkit-scrollbar
width: 12px
height: 12px
cursor: pointer
background-color: theme(colors.gray.100)
*::-webkit-scrollbar-thumb
border-radius: 9999px
background-color: theme(colors.gray.300)
border: 4px solid theme(colors.gray.100)
&:hover
background-color: theme(colors.gray.400)
border-width: 3px

3
client/src/Util.ts Normal file
View File

@ -0,0 +1,3 @@
export function mergeClasses(...classes: (string | undefined | null | false)[]): string {
return classes.filter(s => s).join(' ');
}

65
client/tailwind.config.js Normal file
View File

@ -0,0 +1,65 @@
const colors = require('tailwindcss/colors');
const defaultTheme = require('tailwindcss/defaultTheme');
const round = (num) => num.toFixed(7).replace(/(\.[0-9]+?)0+$/, '$1').replace(/\.0$/, '');
const em = (px, base) => `${round(px / base)}em`
module.exports = {
mode: 'jit',
purge: [
'./src/*.sss',
'./src/*.tsx'
],
darkMode: true,
theme: {
extend: {
transitionDelay: {
'0': '0ms'
},
fontFamily: {
sans: [ 'Roboto', ...defaultTheme.fontFamily.sans ]
},
spacing: {
'18': '4.5rem',
},
transitionTimingFunction: {
'bounce': 'cubic-bezier(0, 0.94, 0.38, 1.91)',
},
typography: {
'DEFAULT': {
css: {
lineHeight: round(24 / 16),
p: {
marginTop: em(9, 16),
marginBottom: em(9, 16)
}
}
}
}
},
colors: {
transparent: 'transparent',
current: 'currentColor',
white: colors.white,
black: colors.black,
gray: {
50: '#171D23',
100: '#1F2630',
200: '#242D3A',
300: '#303D4C',
400: '#596A7D',
500: '#74879D',
600: '#879CB3',
700: '#AABCCF',
800: '#D0DDEB',
900: '#EAF0F6'
},
blue: colors.blue,
indigo: colors.indigo
},
},
plugins: [
require('tailwindcss-interaction-variants'),
require('@tailwindcss/typography'),
]
}

30
client/tsconfig.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"strict": true,
"alwaysStrict": true,
"target": "es6",
"module": "commonjs",
"esModuleInterop": true,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"jsx": "react",
"jsxFactory": "h",
"typeRoots": [ "./node_modules/@types/" ],
"removeComments": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
},
"include": [
"./src/**/*.ts"
],
"exclude": [
"./node_modules"
]
}

105
client/webpack.ts Normal file
View File

@ -0,0 +1,105 @@
import { resolve } from 'path';
import * as Webpack from 'webpack';
import { merge } from 'webpack-merge';
const LiveReloadPlugin = require('webpack-livereload-plugin');
const MiniCSSExtractPlugin = require('mini-css-extract-plugin');
const CSSMinimizerPlugin = require('css-minimizer-webpack-plugin');
const ForkTsCheckerPlugin = require('fork-ts-checker-webpack-plugin');
export default function(_: {}, argv: { mode: string; analyze: boolean }) {
const mode: 'production' | 'development' = argv.mode as any ?? 'development';
process.env.NODE_ENV = mode;
let config: Webpack.Configuration = {
mode: mode,
name: 'aether',
stats: 'errors-warnings',
devtool: mode === 'production' ? undefined : 'nosources-source-map',
context: resolve(__dirname),
entry: { main: './src/Main.ts' },
resolve: {
extensions: [ '.ts', '.tsx', '.js', '.jsx' ],
alias: {
'react': 'preact/compat',
'react-dom': 'preact/compat'
}
},
output: {
path: resolve(__dirname, './build')
},
plugins: [
new ForkTsCheckerPlugin({
typescript: {
configFile: resolve(__dirname, 'tsconfig.json')
},
eslint: {
files: './src/**/*.{ts,tsx}',
options: {
configFile: resolve(__dirname, '.eslintrc.js'),
emitErrors: true,
failOnHint: true,
typeCheck: true
}
}
}),
new MiniCSSExtractPlugin()
],
module: {
rules: [{
test: /\.[t|j]sx?$/,
loader: 'babel-loader',
options: {
babelrc: false,
cacheDirectory: true,
presets: [
['@babel/preset-typescript', {
isTSX: true,
allExtensions: true,
jsxPragma: 'h'
}],
[ '@babel/preset-env', {
targets: { browsers: [ 'Chrome 78' ] }
}]
],
plugins: [
[ '@babel/transform-react-jsx', { pragma: 'h' } ],
[ '@babel/plugin-proposal-class-properties' ],
[ '@babel/plugin-proposal-private-methods' ]
]
}
}, {
test: /\.tw$/,
use: [
MiniCSSExtractPlugin.loader,
{ loader: 'css-loader', options: { url: false, importLoaders: 1 } },
'postcss-loader'
]
}, {
test: /\.sss$/,
use: [
MiniCSSExtractPlugin.loader,
{ loader: 'css-loader', options: { url: false, importLoaders: 1, modules: {
localIdentName: mode === 'development' ? '[local]_[hash:base64:4]' : '[hash:base64:8]'
} } },
'postcss-loader'
]
}]
},
optimization: {
minimizer: [
'...',
new CSSMinimizerPlugin()
]
}
};
if (mode === 'development') config = merge(config, { plugins: [ new LiveReloadPlugin({ delay: 500 }) ] });
return config;
}

168
common/.eslintrc.js Executable file
View File

@ -0,0 +1,168 @@
/*
👋 Hi! This file was autogenerated by tslint-to-eslint-config.
https://github.com/typescript-eslint/tslint-to-eslint-config
It represents the closest reasonable ESLint configuration to this
project's original TSLint configuration.
We recommend eventually switching this configuration to extend from
the recommended rulesets in typescript-eslint.
https://github.com/typescript-eslint/tslint-to-eslint-config/blob/master/docs/FAQs.md
Happy linting! 💖
*/
module.exports = {
"root": true,
"env": {
"browser": true
},
"extends": [
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"eslint-plugin-jsdoc"
],
"rules": {
"@typescript-eslint/dot-notation": "error",
"@typescript-eslint/indent": [
"error",
"tab",
{
"CallExpression": {
"arguments": 1
},
"FunctionDeclaration": {
"parameters": 1
},
"FunctionExpression": {
"parameters": 1
}
}
],
"@typescript-eslint/member-delimiter-style": [
"error",
{
"multiline": {
"delimiter": "semi",
"requireLast": true
},
"singleline": {
"delimiter": "semi",
"requireLast": false
}
}
],
"@typescript-eslint/member-ordering": "error",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-unused-expressions": "error",
"@typescript-eslint/no-use-before-define": "error",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/quotes": [
"error",
"single"
],
"@typescript-eslint/semi": [
"error",
"always"
],
"@typescript-eslint/type-annotation-spacing": "error",
"brace-style": [
"error",
"stroustrup",
{ "allowSingleLine": true }
],
"comma-dangle": "error",
"curly": "off",
"default-case": "error",
"eol-last": "error",
"eqeqeq": [
"error",
"smart"
],
"guard-for-in": "error",
"id-blacklist": [
"error",
"any",
"Number",
"number",
"String",
"string",
"Boolean",
"boolean",
"Undefined",
"undefined"
],
"id-match": "error",
"jsdoc/check-alignment": "error",
"jsdoc/check-indentation": "error",
"jsdoc/newline-after-description": "error",
"max-len": [
"error", {
"code": 150
}
],
"no-bitwise": "error",
"no-caller": "error",
"no-console": [
"error",
{
"allow": [
"log",
"dirxml",
"warn",
"error",
"dir",
"timeLog",
"assert",
"clear",
"count",
"countReset",
"group",
"groupCollapsed",
"groupEnd",
"table",
"Console",
"markTimeline",
"profile",
"profileEnd",
"timeline",
"timelineEnd",
"timeStamp",
"context"
]
}
],
"no-debugger": "error",
"no-empty": "error",
"no-eval": "error",
"no-fallthrough": "error",
"no-new-wrappers": "error",
"no-redeclare": "error",
"no-trailing-spaces": [
"error", {
"skipBlankLines": true
}
],
"no-unused-labels": "error",
"no-var": "error",
"radix": "error",
"spaced-comment": [
"error",
"always",
{
"markers": [
"/"
]
}
]
}
};

5
common/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
!build/
build/*
!build/package.json
!build/package_lock.json

30
common/build/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "@aether/common",
"version": "0.0.1",
"private": true,
"description": "Common definitions and utilities for the Aether Mail software.",
"main": "./Main.js",
"types": "./Main.d.ts",
"scripts": {
"prepublishOnly": "cd ../; npm run clean; npm run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Aurailus/Aether.git"
},
"keywords": [
"mail",
"email",
"cloud",
"aether"
],
"author": "Auri Collings <me@auri.xyz>",
"license": "UNLICENSED",
"bugs": {
"url": "https://github.com/Aurailus/Aether/issues"
},
"homepage": "https://github.com/Aurailus/Aether#readme",
"dependencies": {
"tslib": "^2.2.0"
}
}

1585
common/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
common/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "@aether/common-build",
"version": "0.0.1",
"private": true,
"description": "Builds common definitions and utilities for the Aether Mail software. DO NOT DEPEND ON.",
"main": "build/Main.js",
"scripts": {
"dev": "nodemon",
"build": "tsc --project tsconfig.json",
"clean": "find build -name '*' -not -name 'package.json' -not -path 'build/node_modules*' -not -name 'build' -delete"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Aurailus/Aether.git"
},
"keywords": [
"mail",
"email",
"cloud",
"aether"
],
"author": "Auri Collings <me@auri.xyz>",
"license": "UNLICENSED",
"nodemonConfig": {
"watch": [
"src"
],
"ext": ".ts,.tsx,.html",
"exec": "npm run build",
"quiet": true
},
"bugs": {
"url": "https://github.com/Aurailus/Aether/issues"
},
"homepage": "https://github.com/Aurailus/Aether#readme",
"devDependencies": {
"electron": "^13.1.2",
"nodemon": "^2.0.7",
"ts-node": "^10.0.0",
"typescript": "^4.3.2"
},
"dependencies": {
"tslib": "^2.2.0"
}
}

0
common/src/Main.ts Normal file
View File

View File

@ -0,0 +1,56 @@
import { Interface as Contact, Query as ContactQuery } from './Contact';
import { Interface as Conversation, Query as ConversationQuery } from './Conversation';
import { ID } from './Basic';
export interface MetaInterface {
id: ID;
name: string;
image?: string;
address: string;
unread: boolean;
}
export interface Interface extends MetaInterface {
messages: string[];
contacts: Contact[];
conversations: Conversation[];
}
export const Schema = `
type Account {
id: ID!
name: String!
image: String
address: String!
unread: Boolean!
messages: [String!]!
contacts: [Contact!]!
conversations: [Conversation!]!
}
`;
export const Query = `
{
id
name
image
address
unread
messages
contacts ${ContactQuery}
conversations ${ConversationQuery}
}
`;
export const MetaQuery = `
{
id
name
image
address
unread
}
`;

View File

@ -0,0 +1,7 @@
export type ID = string;
export type Date = number;
export const Schema = `
scalar Date
`;

View File

@ -0,0 +1,18 @@
export interface Interface {
name: string;
addresses: string[];
}
export const Schema = `
type Contact {
name: String!
addresses: [String!]!
}
`;
export const Query = `
{
name
addresses
}
`;

View File

@ -0,0 +1,35 @@
import { ID } from './Basic';
export interface Interface {
id: ID;
unread: boolean;
title: string;
lastMessage: Date;
messages: string[];
participants: string[];
}
export const Schema = `
type Conversation {
id: ID!
unread: Boolean!
title: String!
lastMessage: Date!
messages: [String!]!
participants: [String!]!
}
`;
export const Query = `
{
id
unread
title
lastMessage
messages
participants
}
`;

View File

@ -0,0 +1,37 @@
import { ID, Date } from './Basic';
export interface Interface {
id: ID;
date: Date;
from: string;
to: string[];
html: string;
markdown: string;
}
export const Schema = `
type Message {
id: ID!
date: Date!
from: String!
to: [String!]!
html: String!
markdown: String!
}
`;
export const Query = `
{
id
date
from
to
markdown
}
`;

View File

@ -0,0 +1,4 @@
export { Query as Message } from './Message';
export { Query as Contact } from './Contact';
export { Query as Conversation } from './Conversation';
export { Query as Account, MetaQuery as AccountMeta } from './Account';

16
common/src/graph/Root.ts Normal file
View File

@ -0,0 +1,16 @@
import { Interface as Message } from './Message';
import { Interface as Account } from './Account';
export interface Interface {
accounts: Omit<Account, 'messages' | 'conversations'>[];
account: Account;
messages: Message[];
}
export const Schema = `
type Query {
accounts: [Account!]!
account(account: String!): Account
messages(account: String!, ids: [String!]!): [Message!]!
}
`;

View File

@ -0,0 +1,8 @@
import { Schema as Basic } from './Basic';
import { Schema as Account } from './Account';
import { Schema as Contact } from './Contact';
import { Schema as Message } from './Message';
import { Schema as Conversation } from './Conversation';
import { Schema as Root } from './Root';
export const SCHEMA = [ Basic, Contact, Message, Conversation, Account, Root ].join('\n');

View File

@ -0,0 +1,5 @@
export * as Query from './Query';
export * as Schema from './Schema';
export * as Type from './type';
export { SCHEMA } from './Schema';

7
common/src/graph/type.ts Normal file
View File

@ -0,0 +1,7 @@
export { Interface as Root } from './Root';
export { Interface as Message } from './Message';
export { Interface as Contact } from './Contact';
export { Interface as Conversation } from './Conversation';
export { Interface as Account, MetaInterface as AccountMeta } from './Account';
export { ID, Date } from './Basic';

30
common/tsconfig.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"strict": true,
"alwaysStrict": true,
"target": "es6",
"module": "commonjs",
"esModuleInterop": true,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"typeRoots": [ "./node_modules/@types/" ],
"outDir": "./build",
"declaration": true,
"removeComments": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
},
"include": [
"./src/**/*.ts"
],
"exclude": [
"./node_modules"
]
}

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "aether-mono",
"version": "0.0.1",
"private": true,
"description": "Controls modules in the Aether Monorepo.",
"scripts": {
"audit": "cd common; npm audit; cd ..; cd server; npm audit; cd ..; cd client; npm audit",
"install": "cd common; npm install; cd ..; cd server; npm install; cd ..; cd client; npm install",
"dev": "npm run dev-common & npm run dev-server & npm run dev-client",
"dev-common": "cd common; npm run dev",
"dev-server": "cd server; npm run dev",
"dev-client": "cd client; npm run dev",
"build": "npm run build-common; npm run build-client; npm run build-server",
"build-common": "cd common; npm run build",
"build-server": "cd server; npm run build",
"build-client": "cd client; npm run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Aurailus/Aether.git"
},
"keywords": [
"mail",
"email",
"cloud",
"aether"
],
"author": "Auri Collings <me@auri.xyz>",
"license": "UNLICENSED",
"bugs": {
"url": "https://github.com/Aurailus/Aether/issues"
},
"homepage": "https://github.com/Aurailus/Aether#readme"
}

168
server/.eslintrc.js Executable file
View File

@ -0,0 +1,168 @@
/*
👋 Hi! This file was autogenerated by tslint-to-eslint-config.
https://github.com/typescript-eslint/tslint-to-eslint-config
It represents the closest reasonable ESLint configuration to this
project's original TSLint configuration.
We recommend eventually switching this configuration to extend from
the recommended rulesets in typescript-eslint.
https://github.com/typescript-eslint/tslint-to-eslint-config/blob/master/docs/FAQs.md
Happy linting! 💖
*/
module.exports = {
"root": true,
"env": {
"browser": true
},
"extends": [
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"eslint-plugin-jsdoc"
],
"rules": {
"@typescript-eslint/dot-notation": "error",
"@typescript-eslint/indent": [
"error",
"tab",
{
"CallExpression": {
"arguments": 1
},
"FunctionDeclaration": {
"parameters": 1
},
"FunctionExpression": {
"parameters": 1
}
}
],
"@typescript-eslint/member-delimiter-style": [
"error",
{
"multiline": {
"delimiter": "semi",
"requireLast": true
},
"singleline": {
"delimiter": "semi",
"requireLast": false
}
}
],
"@typescript-eslint/member-ordering": "error",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-unused-expressions": "error",
"@typescript-eslint/no-use-before-define": "error",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/quotes": [
"error",
"single"
],
"@typescript-eslint/semi": [
"error",
"always"
],
"@typescript-eslint/type-annotation-spacing": "error",
"brace-style": [
"error",
"stroustrup",
{ "allowSingleLine": true }
],
"comma-dangle": "error",
"curly": "off",
"default-case": "error",
"eol-last": "error",
"eqeqeq": [
"error",
"smart"
],
"guard-for-in": "error",
"id-blacklist": [
"error",
"any",
"Number",
"number",
"String",
"string",
"Boolean",
"boolean",
"Undefined",
"undefined"
],
"id-match": "error",
"jsdoc/check-alignment": "error",
"jsdoc/check-indentation": "error",
"jsdoc/newline-after-description": "error",
"max-len": [
"error", {
"code": 150
}
],
"no-bitwise": "error",
"no-caller": "error",
"no-console": [
"error",
{
"allow": [
"log",
"dirxml",
"warn",
"error",
"dir",
"timeLog",
"assert",
"clear",
"count",
"countReset",
"group",
"groupCollapsed",
"groupEnd",
"table",
"Console",
"markTimeline",
"profile",
"profileEnd",
"timeline",
"timelineEnd",
"timeStamp",
"context"
]
}
],
"no-debugger": "error",
"no-empty": "error",
"no-eval": "error",
"no-fallthrough": "error",
"no-new-wrappers": "error",
"no-redeclare": "error",
"no-trailing-spaces": [
"error", {
"skipBlankLines": true
}
],
"no-unused-labels": "error",
"no-var": "error",
"radix": "error",
"spaced-comment": [
"error",
"always",
{
"markers": [
"/"
]
}
]
}
};

3082
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
server/package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "@aether/server",
"version": "0.0.1",
"private": true,
"description": "Server / Electron Main for the Aether Mail software.",
"main": "build/Main.js",
"scripts": {
"dev": "nodemon",
"lint": "eslint -c .eslintrc.js src/**/*.ts",
"build": "tsc --project tsconfig.json"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Aurailus/Aether.git"
},
"keywords": [
"mail",
"email",
"aether"
],
"author": "Auri Collings <me@auri.xyz>",
"license": "UNLICENSED",
"nodemonConfig": {
"watch": [
"src"
],
"ext": ".ts,.tsx,.html",
"exec": "npm run lint & npm run build && electron .",
"quiet": true
},
"bugs": {
"url": "https://github.com/Aurailus/Aether/issues"
},
"homepage": "https://github.com/Aurailus/Aether#readme",
"devDependencies": {
"@types/imap": "^0.8.34",
"@types/md5": "^2.3.1",
"electron": "^13.1.2",
"nodemon": "^2.0.7",
"ts-node": "^10.0.0",
"typescript": "^4.3.2",
"@typescript-eslint/eslint-plugin": "^4.29.3",
"@typescript-eslint/parser": "^4.29.3",
"eslint": "^7.32.0",
"eslint-plugin-jsdoc": "^36.0.8"
},
"dependencies": {
"common": "file:../common/build",
"graphql": "^15.5.0",
"imap": "^0.8.19",
"log4js": "^6.3.0",
"md5": "^2.3.0",
"mongodb": "^4.1.1",
"mongoose": "^6.0.1",
"tslib": "^2.2.0",
"typegoose": "^5.9.1"
}
}

148
server/src/Account.ts Normal file
View File

@ -0,0 +1,148 @@
import Message from './Message';
import Conversation from './Conversation';
import Imap, { ConnectionProperties, Message as RawMessage, MailboxType } from './Imap';
interface Contact {
name: string;
addresses: Set<string>;
}
export default class Account {
private imap: Imap;
private contacts: Contact[] = [];
private conversations: Conversation[] = [];
private name: string;
private image: string;
private address: string;
private unread: boolean;
constructor(name: string, image: string, connection: ConnectionProperties) {
this.name = name;
this.image = image;
this.address = connection.username;
this.unread = true;
this.imap = new Imap(connection);
}
async connect() {
await this.imap.connect();
const messages = await this.fetchAllMessages();
this.conversations = this.createConversations(messages).filter(c => c.active);
this.contacts = this.createContacts(messages);
}
getName() {
return this.name;
}
getAddress() {
return this.address;
}
getImage() {
return this.image;
}
hasUnreads() {
return this.unread;
}
getConversations() {
return this.conversations;
}
getContacts() {
return this.contacts;
}
getMessages(_messages: string[]): Message[] {
return [{
id: 'AOUEOAEu',
date: new Date(),
from: 'me@auri.xyz',
to: [ 'nicole@aurailus.design' ],
content: '<p>Lorem ipsum dolor sit amet.</p>'
}];
}
async fetchAllMessages(): Promise<RawMessage[]> {
const boxes = (await this.imap.listBoxes()).filter(box =>
!box.treeTypes.has(MailboxType.Spam) && !box.treeTypes.has(MailboxType.Trash));
let allMeta: RawMessage[] = [];
for (let box of boxes) {
await this.imap.openBox(box.path);
const meta = await this.imap.fetchMessages('1:*');
Object.keys(meta).forEach(id => allMeta.push(meta[id]));
}
allMeta = allMeta.sort((a, b) => +a.date - +b.date);
return allMeta;
}
private createConversations(messages: RawMessage[]): Conversation[] {
const conversations: Conversation[] = [];
messages.forEach(message => {
if (message.replyTo) {
for (let conversation of conversations) {
for (let reference of [ ...message.references, message.replyTo ]) {
if (conversation.messages.has(reference)) {
conversation.date = message.date;
conversation.messages.add(message.messageId);
conversation.title = this.cleanSubjectLine(message.subject);
conversation.active = conversation.active || message.active;
message.to.forEach(p => conversation.participants.add(p.address));
conversation.participants.add(message.from.address);
return;
}
}
}
}
conversations.push({
title: this.cleanSubjectLine(message.subject),
messages: new Set([ message.messageId ]),
date: message.date,
active: message.active,
participants: new Set([ message.from.address, ...message.to.map(p => p.address) ])
});
});
conversations.forEach(conversation => {
conversation.participants.delete(this.address);
});
return conversations.sort((a, b) => +a.date - +b.date);
}
private createContacts(messages: RawMessage[]): Contact[] {
const contacts: Contact[] = [];
for (let message of messages) {
[ message.from, ...message.to ].forEach(participant => {
for (let contact of contacts) {
if (contact.addresses.has(participant.address)) {
if (participant.name) contact.name = participant.name;
return;
}
}
contacts.push({
name: participant.name ?? participant.address,
addresses: new Set([ participant.address ])
});
});
}
return contacts;
}
private cleanSubjectLine(subject: string = '') {
return subject.replace(/^((re|fwd?|b?cc)(:| ) *)*/gi, '').trim();
}
};

View File

@ -0,0 +1,7 @@
export default interface Conversation {
title: string;
date: Date;
messages: Set<string>;
participants: Set<string>;
active: boolean;
}

View File

@ -0,0 +1,27 @@
import path from 'path';
import { app, BrowserWindow, dialog } from 'electron';
dialog.showErrorBox = () => { /* Disable displaying error box. */ };
export async function openWindow() {
app.whenReady().then(() => {
const window = new BrowserWindow({
width: 1400,
height: 800,
title: 'Aether',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
enableRemoteModule: false,
preload: path.join(__dirname, 'Preload.js')
}
});
window.removeMenu();
window.loadFile('src/View.html');
window.webContents.openDevTools();
});
app.on('window-all-closed', () => app.quit());
}

61
server/src/Graph.ts Normal file
View File

@ -0,0 +1,61 @@
import { buildSchema } from 'graphql';
import { Type, SCHEMA } from 'common/graph';
import Message from './Message';
import Account from './Account';
import Conversation from './Conversation';
export interface Context {
accounts: Record<Type.ID, Account>;
}
export const Schema = buildSchema(SCHEMA);
function messageResolver(message: Message) {
return {
id: message.id,
date: message.date,
from: message.from,
to: message.to,
html: () => message.content,
markdown: () => message.content
};
}
function conversationResolver(conversation: Conversation, id: string) {
return {
id: id,
unread: false,
title: conversation.title,
lastMessage: conversation.date,
messages: conversation.messages,
participants: conversation.participants
};
}
function accountResolver(account: Account, id: string) {
return {
id: id,
name: account.getName(),
image: account.getImage(),
address: account.getAddress(),
unread: account.hasUnreads(),
messages: [],
contacts: () => account.getContacts(),
conversations: () => {
const conversations = account.getConversations();
for (let key in conversations) if (!conversations[key].active) delete conversations[key];
return Object.keys(conversations).map(id => conversationResolver(conversations[id as any], id));
}
};
}
export const Resolver = {
accounts: (_: any, ctx: Context) => Object.keys(ctx.accounts).map(id => accountResolver(ctx.accounts[id], id)),
account: ({ account: id }: { account: string }, ctx: Context) => accountResolver(ctx.accounts[id], id),
messages: async ({ account, ids }: { account: string; ids: string[] }, ctx: Context) =>
(await ctx.accounts[account].getMessages(ids)).map(msg => messageResolver(msg))
};

375
server/src/Imap.ts Normal file
View File

@ -0,0 +1,375 @@
import md5 from 'md5';
import RawImap, { MailBoxes as RawBoxes } from 'imap';
/** Credentials and properties used to establish an IMAP connection. */
export interface ConnectionProperties {
username: string;
password: string;
host: string;
port: number;
tls: boolean;
}
/**
* Mailbox types (attributes).
* Only the attributes relevant to Aether are included,
* and some are named differently to better match interface language.
*/
export enum MailboxType {
Box = 'NORMAL_BOX',
All = '\\All',
Archive = '\\Archive',
Drafts = '\\Drafts',
Starred = '\\Flagged',
Important = '\\Important',
Inbox = '\\Inbox',
Spam = '\\Junk',
Sent = '\\Sent',
Trash = '\\Trash'
}
/**
* Message flags (keywords).
* Only the flags relevant to Aether are included,
* and some are named differently to better match interface language.
*/
export enum MessageFlag {
Read = '\\Seen',
Important = '\\Flagged',
Deleted = '\\Deleted',
Draft = '\\Draft',
Active = '$ACTIVE'
}
/** An IMAP Box. */
export interface Mailbox {
name: string;
path: string;
delimiter: string;
type: MailboxType;
treeTypes: Set<MailboxType>;
parent?: string;
children: string[];
}
/** A simple key-value set of raw message headers. */
export type RawMessageHeaders = Record<string, string>;
/** Raw message attributes. */
export type MessageAttrs = {
date: Date;
flags: Set<MessageFlag>;
uid: number;
};
/**
* A parsed conversation participant (sender / receiver).
* Some participants don't have names included.
*/
export interface Participant { name: string | undefined; address: string };
/** Headers for an email message, identified by a persistant ID string. */
export interface Message {
to: Participant[];
from: Participant;
date: Date;
subject: string;
messageId: string;
active: boolean;
id: number;
boxId: number;
replyTo: string;
references: string[];
}
/**
* A wrapper on a node-imap connection that provides
* a promise-based wrapper to an IMAP connection.
* Returns more friendly interfaces than the raw connection,
* and keeps track of the logged in state and current box.
*/
export default class Imap {
private raw: RawImap;
private connected: boolean = false;
private boxes: Mailbox[] = [];
private box: Mailbox | undefined = undefined;
/**
* Constructs an Imap instance, but does not connect.
* connect() must be called before accessing the server.
*
* @param props - Connection properties to initialize with.
*/
constructor(props: ConnectionProperties) {
this.raw = new RawImap({
user: props.username,
password: props.password,
host: props.host,
port: props.port,
tls: props.tls
});
}
/**
* Connects to the remote IMAP server.
*
* @returns a promise resolving upon connection or rejecting with a connection error.
*/
connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.connected) {
reject('Already connected.');
return;
}
this.raw.once('end', () => this.connected = false);
this.raw.once('error', (error: any) => reject(error));
this.raw.once('ready', async () => {
this.connected = true;
this.boxes = await this.listBoxes();
resolve();
});
this.raw.connect();
});
}
/**
* Gets the current connection state.
*
* @returns a boolean indicating the connection state of the instance.
*/
isConnected(): boolean {
return this.connected;
}
/**
* Gets a flat list of all the mailboxes mailboxes.
*
* @returns a promise resolving to an array of mailboxes, or rejecting with an error.
*/
listBoxes(): Promise<Mailbox[]> {
return new Promise((resolve, reject) => {
if (!this.connected) reject('Cannot get box when the connection is closed.');
this.raw.getBoxes((err, boxes) => {
if (err) {
reject(err);
return;
}
const foundBoxes: Mailbox[] = [];
function traverseBoxes(boxes: RawBoxes, path: string, parent: string | undefined, treeTypes: Set<MailboxType>) {
for (let boxName in boxes) {
if ({}.hasOwnProperty.call(boxes, boxName)) {
const box = boxes[boxName];
const thisPath = path + boxName + box.delimiter;
const type = (box as any).special_use_attrib as MailboxType ??
(boxName === 'INBOX' ? MailboxType.Inbox : MailboxType.Box);
const thisTreeTypes = new Set([ ...treeTypes, type ]);
foundBoxes.push({
name: boxName,
path: path + boxName,
delimiter: box.delimiter,
type,
treeTypes: thisTreeTypes,
parent,
children: Object.keys(box.children ?? []).map(name => thisPath + name)
});
traverseBoxes(box.children ?? [], thisPath, path + boxName, thisTreeTypes);
}
}
}
traverseBoxes(boxes, '', undefined, new Set());
resolve(foundBoxes);
});
});
}
/**
* Attempts to open a box with the path provided, and returns that box.
*
* @param box - The path of the box to open.
* @returns a raw node-imap box instance.
*/
openBox(box: string): Promise<Mailbox> {
return new Promise((resolve, reject) => {
if (!this.connected) reject('Cannot get box when the connection is closed.');
this.raw.openBox(box, false, (err, box) => {
if (err) {
reject(err);
return;
}
this.box = this.boxes.filter(b => b.path === box.name)[0];
resolve(this.box);
});
});
}
/**
* Gets the name of the currently opened box.
*
* @returns the name of the currently opened box, or undefined if none are open.
*/
getCurrentBox(): string | undefined {
return this.box?.name;
}
/**
* Fetches a set of messages from the server matching the query provided.
* This query should be a string matching the IMAP query selector format.
*
* @param query - A query to send to the server.
* @param seq - Whether or not the query is a sequence query or an ID query.
* @returns a promise resolving to a record of messages, indexed by persistant Message ID, or rejecting with an error.
*/
async fetchMessages(query: string | number | number[]): Promise<Record<string, Message>> {
const bodies = await this.fetchMessageHeaders(query, false,
'HEADER.FIELDS (FROM TO SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)');
const messages: Record<string, Message> = {};
Object.keys(bodies).forEach(idStr => {
const id = parseInt(idStr, 10);
const headers = bodies[id].headers;
const attrs = bodies[id].attrs;
messages[headers['MESSAGE-ID']] = {
to: this.parseParticipants(headers.TO ?? ''),
from: this.parseParticipants(headers.FROM ?? '')[0],
subject: headers.SUBJECT,
date: attrs.date,
id: id,
boxId: attrs.uid,
messageId: headers['MESSAGE-ID'] || `HASH:${md5(attrs.date.toString())}:${md5(headers.SUBJECT)}`,
active: attrs.flags.has(MessageFlag.Active) || this.box!.type === MailboxType.Inbox,
replyTo: headers['IN-REPLY-TO'],
references: (headers.REFERENCES ?? '').split(' ').map(s => s.trim()).filter(r => r)
};
});
return messages;
}
/**
* Fetches a set of message headers from the server matching the query provided.
* This query should match the node-imap query selector format.
* The bodies should be presented in the node-imap format, e.g.
* 'HEADER.FIELDS (FROM TO SUBJECT DATE)'
* Attributes are also returned.
*
* @param query - A query to send to the server.
* @param seq - Whether or not the query is a sequence query or an ID query.
* @param bodies - The string identifying the bodies to fetch.
* @returns a promise resolving to a record of message bodies, indexed by persistant Message ID, or rejecting with an error.
*/
fetchMessageHeaders(query: string | number | number[], seq: boolean, bodies: string):
Promise<Record<number, { headers: RawMessageHeaders; attrs: MessageAttrs }>> {
return new Promise((resolve, reject) => {
if (!this.connected) reject('Cannot fetch messages when the connection is closed.');
if (!this.box) reject('Cannot fetch messages when there is no current box.');
let fetchRoot = seq ? this.raw.seq : this.raw;
const rawAttrs: Record<number, any> = {};
const rawHeaders: Record<number, string> = {};
const fetch = fetchRoot.fetch(query, { bodies: bodies, struct: false });
fetch.on('error', err => reject(err));
fetch.on('message', (msg, id) => {
rawHeaders[id] = '';
msg.on('body', stream => stream.on('data', (chunk) => rawHeaders[id] += chunk.toString('utf8')));
msg.once('attributes', (attrs) => rawAttrs[id] = attrs);
});
fetch.on('end', () => {
const messages: Record<number, { headers: RawMessageHeaders; attrs: MessageAttrs }> = {};
Object.keys(rawHeaders).forEach(idStr => {
const id = parseInt(idStr, 10);
const finalHeaders: Record<string, string> = {};
rawHeaders[id].split(/\r?\n(?=[A-z:\-_]+)/g).map(h => h.trim()).filter(h => h).forEach(header => {
const delimiter = header.indexOf(':');
const name = header.substr(0, delimiter).trim();
const value = header.substr(delimiter + 1).trim();
finalHeaders[name.toUpperCase()] = value;
});
const myAttrs = rawAttrs[id];
messages[id] = {
headers: finalHeaders,
attrs: {
uid: myAttrs.uid,
date: myAttrs.date,
flags: new Set(myAttrs.flags)
}
};
});
resolve(messages);
});
});
}
/**
* Parses a participant list,
* e.g 'Auri Collings <me@auri.xyz>, nicole@aurailus.design'
* into a parsed array of Participant objects.
* This function WILL return invalid results if a name contains a comma.
*
* @returns an array of Participants.
*/
private parseParticipants(header: string): Participant[] {
return header.split(',').map(raw => {
const delimiter = raw.indexOf('<');
if (delimiter === -1) return { name: undefined, address: raw.trim() };
const name = raw.substr(0, delimiter).replace(/^[\s'"]+/g, '').trim().replace(/[\s'"]+$/g, '');
const address = raw.substr(delimiter + 1).replace(/[<>]/g, '').replace(/^[\s'"]+/g, '').trim().replace(/[\s'"]+$/g, '');
return { name: name ? name : undefined, address };
});
}
}

52
server/src/Log.ts Normal file
View File

@ -0,0 +1,52 @@
/**
* Formats log4js's pattern based on the information available,
* configures the perf log level, and allows easy access to logger instance methods.
* Also adds perfStart and perfEnd methods, which allow easy performance logging.
*/
import log4js from 'log4js';
const DEV_PATTERN = '[ %[%-5p%] | %[%-10.10f{1}%] %3l:%-2o ] %[%m%]';
const BUILD_PATTERN = '[ %d | %[%-5p%] ] %[%m%]';
const dev = !__filename.startsWith('/snapshot');
const pattern = dev ? DEV_PATTERN : BUILD_PATTERN;
log4js.configure({
levels: { PERF: { value: log4js.levels.TRACE.level, colour: 'yellow' } } as any,
appenders: { out: { type: 'stdout', layout: { type: 'pattern', pattern } } },
categories: { default: { appenders: ['out'], level: 'info', enableCallStack: dev } }
});
const logger = log4js.getLogger();
const activePerfs: Record<string, [ number, number ]> = {};
const perfStart = (identifier: string) => {
activePerfs[identifier] = process.hrtime();
};
const perfEnd = (identifier: string) => {
let perf = activePerfs[identifier];
if (!perf) logger.warn('Attempted to perf invalid identifier \'%s\'.', identifier);
else {
const elapsed = process.hrtime(perf)[1] / 1000000;
// @ts-ignore
logger.perf('%s took %s ms.', identifier, elapsed.toFixed(3));
delete activePerfs[identifier];
}
};
export default {
// @ts-ignore
perf: logger.perf.bind(logger),
perfStart: perfStart,
perfEnd: perfEnd,
info: logger.info.bind(logger),
debug: logger.debug.bind(logger),
error: logger.error.bind(logger),
warn: logger.warn.bind(logger),
fatal: logger.fatal.bind(logger),
trace: logger.trace.bind(logger),
setLogLevel: (level: string) => logger.level = level
};

38
server/src/Main.ts Normal file
View File

@ -0,0 +1,38 @@
import fs from 'fs';
import { graphql } from 'graphql';
import { ipcMain } from 'electron';
import Log from './Log';
import Account from './Account';
import { Schema, Resolver } from './Graph';
import { openWindow } from './ElectronWindow';
Log.setLogLevel('debug');
(async () => {
const accounts: Record<string, Account> = {
'0': new Account('Personal', '../../client/res/user-home.png', {
username: 'me@auri.xyz',
password: fs.readFileSync(__dirname + '/../../client/pw.txt').toString().trim(),
host: 'mail.hover.com',
port: 993,
tls: true
}),
'1': new Account('Work', '../../client/res/user-work.png', {
username: 'nicole@aurailus.design',
password: fs.readFileSync(__dirname + '/../../client/pw.txt').toString().trim(),
host: 'mail.hover.com',
port: 993,
tls: true
})
};
await Promise.all(Object.values(accounts).map(account => account.connect()));
ipcMain.handle('graphql', async (_, req: { query: string; data: any }) => {
return graphql(Schema, req.query, Resolver, { accounts }, req.data);
});
openWindow();
})();

10
server/src/Message.ts Normal file
View File

@ -0,0 +1,10 @@
export default interface Message {
id: string;
date: Date;
from: string;
to: string[];
content: string;
}

12
server/src/Preload.ts Normal file
View File

@ -0,0 +1,12 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld(
'aether', {
query: async (query: string, data: any): Promise<any> => {
const res = await ipcRenderer.invoke('graphql', { query, data });
if (res.errors && res.data) console.warn(res.errors);
if (res.data) return res.data;
throw res.errors;
}
}
);

16
server/src/View.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv='Content-Security-Policy' content="default-src 'self' http://localhost:35729 ws://localhost:35729;">
<link rel='preload' as='script' href='../../client/build/main.js'>
<link rel='stylesheet' href='../../client/build/main.css'/>
<script src='http://localhost:35729/livereload.js' async></script>
</head>
<body>
<script src='../../client/build/main.js'></script>
</body>
</html>

29
server/tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"strict": true,
"alwaysStrict": true,
"target": "es6",
"module": "commonjs",
"esModuleInterop": true,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"typeRoots": [ "./node_modules/@types/" ],
"outDir": "./build",
"removeComments": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
},
"include": [
"./src/**/*.ts"
],
"exclude": [
"./node_modules"
]
}