Belated Initial Commit
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
build/
|
|
@ -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": [
|
||||
"/"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
pw.txt
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
]
|
||||
}
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
After Width: | Height: | Size: 334 KiB |
After Width: | Height: | Size: 1.3 MiB |
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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} }`;
|
|
@ -0,0 +1,7 @@
|
|||
import { render, h } from 'preact';
|
||||
|
||||
import App from './App';
|
||||
|
||||
import './Tailwind.tw';
|
||||
|
||||
render(h(App, null), document.body);
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
export function mergeClasses(...classes: (string | undefined | null | false)[]): string {
|
||||
return classes.filter(s => s).join(' ');
|
||||
}
|
|
@ -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'),
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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": [
|
||||
"/"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
!build/
|
||||
build/*
|
||||
|
||||
!build/package.json
|
||||
!build/package_lock.json
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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,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
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,7 @@
|
|||
export type ID = string;
|
||||
|
||||
export type Date = number;
|
||||
|
||||
export const Schema = `
|
||||
scalar Date
|
||||
`;
|
|
@ -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
|
||||
}
|
||||
`;
|
|
@ -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
|
||||
}
|
||||
`;
|
|
@ -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
|
||||
}
|
||||
`;
|
|
@ -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';
|
|
@ -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!]!
|
||||
}
|
||||
`;
|
|
@ -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');
|
|
@ -0,0 +1,5 @@
|
|||
export * as Query from './Query';
|
||||
export * as Schema from './Schema';
|
||||
export * as Type from './type';
|
||||
|
||||
export { SCHEMA } from './Schema';
|
|
@ -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';
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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": [
|
||||
"/"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
export default interface Conversation {
|
||||
title: string;
|
||||
date: Date;
|
||||
messages: Set<string>;
|
||||
participants: Set<string>;
|
||||
active: boolean;
|
||||
}
|
|
@ -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());
|
||||
}
|
|
@ -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))
|
||||
};
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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
|
||||
};
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export default interface Message {
|
||||
id: string;
|
||||
|
||||
|
||||
date: Date;
|
||||
from: string;
|
||||
to: string[];
|
||||
|
||||
content: string;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|