Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Andrew Gundersen
58151b8b91 some polish 2022-03-25 06:35:46 -05:00
Andrew Gundersen
35fea9945c working notifications and content updates 2022-03-24 07:50:48 -05:00
Andrew Gundersen
e00394df78 more simple UI changes 2022-03-22 06:51:59 -05:00
Andrew Gundersen
607ae350ea merge conflict 2022-03-21 07:28:34 -05:00
Andrew Gundersen
70113b8572 Updated .gitignore, reverted annotate content 2022-03-21 07:27:50 -05:00
Andrew Gundersen
ad3b862f4d Fixed .gitignore 2022-03-21 06:55:57 -05:00
Andrew Gundersen
6f7c0f17c5 annotate content patch 2022-03-17 06:54:08 -05:00
Andrew Gundersen
4936c76770 Updated README, fixed config bug, added build to notarize.js 2022-03-14 06:30:59 -05:00
Andrew Gundersen
6fb6407f6a Refine notatize.js and update README.md 2022-03-10 07:16:42 -06:00
Andrew Gundersen
daf13eacb1 fixed isPackaged issue 2022-03-08 07:45:14 -06:00
Andrew Gundersen
6b5aadc492 Applying production bug fixes 2022-03-08 07:23:37 -06:00
Andrew Gundersen
396e6ed546 Sign and Notarize config, debug logs for IPC 2022-03-08 07:17:52 -06:00
Andrew Gundersen
2f6d79e1f0 deleting account 2022-02-21 07:46:06 -06:00
Andrew Gundersen
df61e7bd9c update register form 2022-02-20 10:08:12 -06:00
Andrew Gundersen
fe2d757b50 fixed nav button on mobile 2022-02-20 09:25:09 -06:00
Andrew Gundersen
cf38b3dd15 smart login form, embedded fonts, other improvments 2022-02-19 17:55:51 -06:00
Andrew Gundersen
ead81883b2 applying compatability changes from new-website to this 2022-02-11 11:18:58 -06:00
Andrew Gundersen
1755c670d6 token auth 2022-02-09 08:31:58 -06:00
Andrew Gundersen
dac7edef21 bug fixes and other minor improvments 2022-01-27 16:45:35 -06:00
Andrew Gundersen
bd9d3aa612 new session + blue dots 2022-01-23 12:19:00 -06:00
Andrew Gundersen
8fe1679acc message updates 2022-01-23 10:46:44 -06:00
Andrew Gundersen
fb14847730 reverting changes in polish manually 2022-01-20 11:39:39 -06:00
Andrew Gundersen
664a9d06f9 some initial design changes 2022-01-20 08:58:49 -06:00
Andrew Gundersen
5d74c67648 polish 2022-01-18 16:58:56 -06:00
Andrew Gundersen
d3f99d6133 html bubbles, more simplifications, new session protocol 2022-01-08 16:53:45 -06:00
Andrew Gundersen
0c6a58ea24 html rendering, bug fixes 2021-12-01 19:33:25 -06:00
Andrew Gundersen
d5fd8b39e7 window null bug fix 2021-11-23 13:39:40 -06:00
Andrew Gundersen
7de0555856 recording timer, bux fixes, simplified emitter 2021-11-22 15:59:33 -06:00
Andrew Gundersen
ccb07d594b bux fixes, profile initials 2021-11-18 09:42:05 -06:00
Andrew Gundersen
41528e7e27 production refactor 2021-10-16 09:39:10 -05:00
Andrew Gundersen
92fb472e3c [WIP] -> yarn 3 2021-08-26 13:15:28 -05:00
Andrew Gundersen
f9dcb00e2d [WIP] refactor, upgrading yarn 2021-08-26 12:31:18 -05:00
Andrew Gundersen
a4d6a883b0 WIP:playback queue and tray icon states 2021-08-24 15:04:21 -05:00
Andrew Gundersen
c52ea2bcd2 successfully imports nodeaudio, add yarnrc.yml for registry config 2021-08-19 15:11:14 -05:00
Andrew Gundersen
622c705c35 removing node-abi 2021-08-18 10:58:42 -05:00
Andrew Gundersen
e9972bd70a iohook settings 2021-08-18 10:19:52 -05:00
Enrique Hernandez
6478b1250b Merge branch 'default_tray_icon' into 'dev'
Default tray icon

See merge request crimata/electron-app!11
2021-08-02 12:16:08 +00:00
riqo
f96cd7a1bc fix: remove unused code from preload and config 2021-08-01 12:10:10 -05:00
riqo
3a89dd82df feat: default tray icon 2021-08-01 12:02:05 -05:00
Enrique H. Oliva
13777f9eed poc: tray icon render 2021-08-01 09:49:37 -05:00
Enrique Hernandez
1c87ce45bc Merge branch 'notification_on_focus' into 'dev'
Notification on focus

See merge request crimata/electron-app!9
2021-07-31 14:14:44 +00:00
Enrique H. Oliva
515f67a120 feature: notify newMessage if window is blurred or closed 2021-07-30 08:52:43 -05:00
Enrique H. Oliva
95e73f7c28 WIP: tray icon image 2021-07-29 09:04:35 -05:00
Enrique Hernandez
9de4ff0805 Merge branch 'notification' into 'dev'
Notification

See merge request crimata/electron-app!8
2021-07-29 12:31:19 +00:00
Enrique H. Oliva
f1a5cad4db add window state management to store 2021-07-28 08:49:44 -05:00
Enrique H. Oliva
2dee81fb36 add notification on new message when window is closed 2021-07-28 08:11:34 -05:00
Enrique Hernandez
5811f89942 Merge branch 'protocols' into 'dev'
Protocols

See merge request crimata/electron-app!7
2021-07-25 23:24:24 +00:00
Enrique H. Oliva
29fc9cc000 add icons 2021-07-25 18:24:05 -05:00
Enrique H. Oliva
5a4c1a86f6 fix: package json main file name 2021-07-22 08:36:12 -04:00
Enrique H. Oliva
f2c69516df add socket close connection code and reason 2021-07-13 08:32:05 -05:00
Andrew Gundersen
fa24a8d46a revising previous commit 2021-07-12 07:06:43 -05:00
Andrew Gundersen
cda3bab739 updated protocols 2021-07-11 11:55:38 -05:00
Andrew Gundersen
531a32a5a5 updated protocols 2021-07-11 11:54:39 -05:00
Andrew Gundersen
21687f4b7f Merge branch 'protocols' into 'dev'
Working protocols with platform

See merge request crimata/electron-app!6
2021-06-30 13:08:53 +00:00
Andrew Gundersen
7283e3f6f9 merge changes 2021-06-30 08:06:11 -05:00
Andrew Gundersen
b6b1f3e894 working new protocols 2021-06-29 16:21:18 -05:00
Andrew Gundersen
a791f973dc working protocols 2021-06-29 11:30:35 -05:00
Andrew Gundersen
dd91a9ae9e Merge remote-tracking branch 'origin/dev' into dev
pulling connection status commits
2021-06-28 11:36:25 -05:00
Andrew Gundersen
468bb760a4 Merge branch 'connection_status' into 'dev'
Connection status

See merge request crimata/electron-app!5
2021-06-28 16:34:55 +00:00
Andrew Gundersen
af421e6bfb more updates 2021-06-28 11:32:45 -05:00
Andrew Gundersen
cb40a811fa message anim 2021-06-27 09:30:31 -05:00
Andrew Gundersen
51c4ad8ed7 new message anim 2021-06-25 18:28:29 -05:00
riqo
4afa716251 show connection status poc 2021-06-24 08:57:29 -05:00
riqo
e13f90a6cc add connectionStatus state and component 2021-06-23 08:48:58 -05:00
riqo
81b4d018fb add profile, messages, and connectionStatus shared state 2021-06-23 08:11:36 -05:00
riqo
308dbe2b4e add window focus ipc call 2021-06-23 07:53:00 -05:00
riqo
d00fb0f3be add navbar listener 2021-06-23 07:52:06 -05:00
121 changed files with 19226 additions and 17609 deletions

View file

@ -1,38 +0,0 @@
module.exports = {
root: true,
env: {
node: true
},
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/typescript/recommended"
],
parserOptions: {
ecmaVersion: 2020
},
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ "argsIgnorePattern": "^_" }
]
},
overrides: [
{
files: [
"**/__tests__/*.{j,t}s?(x)",
"**/tests/unit/**/*.spec.{j,t}s?(x)",
"*.js"
],
rules: {
"@typescript-eslint/no-var-requires": "off",
},
env: {
jest: true
}
}
]
};

18
.gitignore vendored
View file

@ -1,7 +1,5 @@
.DS_Store
node_modules
/dist
/crate/target/*
/node_modules
/.log
crash.log
@ -13,8 +11,6 @@ crash.log
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
@ -26,12 +22,8 @@ pnpm-debug.log*
*.sln
*.sw?
#Electron-builder output
/dist_electron
# Dist
*.app
#Electron-builder config
electron-builder.yml
#Window and session states
session.json
window.json
*.sublime-workspace
*.sublime-project

View file

@ -1,63 +0,0 @@
stages:
- Build
- Upload
- Release
build:
stage: Build
tags:
# Use a macos runner to build.
- darwin
before_script:
- yarn
script:
- export VERSION=$(node -e "console.log(require('./package.json').version)")
- echo "VERSION=$VERSION" >> variables.env
- export APPNAME=$(node -e "console.log(require('./package.json').productName)")
- echo "APPNAME=$APPNAME" >> variables.env
- yarn build
artifacts:
reports:
dotenv: variables.env
name: $CI_COMMIT_REF_SLUG
paths:
- dist_electron/*.dmg
- dist_electron/*.zip
- dist_electron/*.yml
when: on_success
only:
- main
variables:
PACKAGE: '${APPNAME}-${VERSION}.dmg'
PACKAGE_REGISTRY_URL: '${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${APPNAME}/${VERSION}'
# Upload package to gitlab registry.
upload:
stage: Upload
needs:
- job: build
artifacts: true
rules:
- if: $CI_COMMIT_TAG
when: never # Do not run this job when a tag is created manually
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when when commits are pushed to the default branch
script:
# Take the package we built and place it in the package registry.
- 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file "dist_electron/${PACKAGE}" "${PACKAGE_REGISTRY_URL}/${PACKAGE}"'
auto-release-master:
image: registry.gitlab.com/gitlab-org/release-cli
needs:
- job: build
artifacts: true
- job: upload
artifacts: true
stage: Release
rules:
- if: $CI_COMMIT_TAG
when: never # Do not run this job when a tag is created manually
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when when commits are pushed to the default branch
script:
- echo "Release $VERSION"
- release-cli create --name "Release $VERSION" --tag-name v$VERSION --description "Release $CI_COMMIT_TITLE" --ref $CI_COMMIT_SHA --assets-link "{\"name\":\"${APPNAME}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${PACKAGE}\"}"

2
.npmrc Normal file
View file

@ -0,0 +1,2 @@
@crimata:registry=https://gitlab.com/api/v4/projects/28849281/packages/npm/
//gitlab.com/api/v4/projects/28849281/packages/npm/:_authToken=${API_TOKEN}

View file

@ -27,3 +27,40 @@ yarn lint
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
## Sign and Notarize
Prerequisites:
* Apple Developer Account ($99)
* Valid Apple Developer ID Application Certificate on keychain
Clear debris from app before signing:
xattr -cr Electron.app
Sign and Notarize:
node notarize.js
Debug electron-osx-sign:
export DEBUG=electron-osx-sign*
### Some things to note:
Notarize only when distributing outside the Mac App Store.
#### Gatekeeper Assess
Electron-osx-sign seems to have a bug where the sign will fail if "gatekeeper-assess" is true (default).
#### Entitlements.plist
Not to be confused with Info.plist, this file specifies entitlements the app can have in hardened runtime (e.g. Can I use the microphone?). Only including the ones Electron reccommends seems to work (including audio of course and omitting allow-unsigned-executable-memory).
#### The Benifit of Electron OSX Sign
Without this package, we would have to manually go in and figure out how to sign each level of the app. While it didn't work out of the box (gatekeeper-assess) it seems to be good otherwise. However, there is a bug where it puts multiple runtime flags on each command - not catastrophic though.
#### Apple Password
This must be an app specific password, not your normal Apple ID password.
#### --Signiture-Flags OSX Sign
Not exactly sure what this does or why its important, but Electron includes it.

View file

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

14
entitlements.plist Normal file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.debugger</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
<!--<key>com.apple.security.cs.disable-library-validation</key>
<true/>-->

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View file

@ -1,6 +0,0 @@
module.exports = {
preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel',
transform: {
'^.+\\.vue$': 'vue-jest'
}
}

View file

@ -1,6 +0,0 @@
{
"typeAcquisition": {
"enable": true
}
}

52
notarize.js Normal file
View file

@ -0,0 +1,52 @@
const exec = require('child_process').exec;
const sign = require("electron-osx-sign").signAsync;
const notarize = require("electron-notarize").notarize;
const app = "Crimata.app";
const dir = `${app}/Contents/Resources/app`;
const runShellCommand = (cmd) => (new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => {
error ? reject(stderr) : resolve(stdout);
});
}));
const signConfig = {
"app": app,
"hardened-runtime": true,
"gatekeeper-assess": false,
"signature-flags": "library",
"entitlements": "entitlements.plist",
"entitlements-inherit": "entitlements.plist",
};
const notarizeConfig = {
appPath: app,
appleId: "gundersena@crimata.com",
appBundleId: "com.crimata.CrimataMessenger",
appleIdPassword: process.env["AC_PASSWORD"]
};
(async function () {
try
{
console.log("Building...");
await runShellCommand(`rm -rf ${dir} && mkdir ${dir} && cp -r {package.json,src,node_modules} ${dir}
`);
console.log("Cleaning...");
await runShellCommand(`xattr -cr ${app}`);
console.log("Signing...");
await sign(signConfig);
console.log("Notarizing...");
await notarize(notarizeConfig);
}
catch (e)
{
console.log(e);
}
})();

1059
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,104 +1,25 @@
{
"name": "Crimata",
"version": "0.9.9",
"productName": "crimata-messenger",
"version": "1.0.0",
"private": true,
"description": "Cross-platform messenger application built with electron, vue3, and TS.",
"description": "Cross-platform messenger app (Electron, Vue3)",
"author": {
"name": "Enrique Hernandez"
"name": "Andrew Gundersen"
},
"main": "src/main.js",
"scripts": {
"build": "vue-cli-service electron:build",
"dev": "vue-cli-service electron:serve",
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps"
"start": "electron ."
},
"main": "init.js",
"dependencies": {
"@google-cloud/speech": "^4.2.0",
"@types/animejs": "^3.1.2",
"@types/bindings": "^1.3.0",
"@types/dom-mediacapture-record": "^1.0.7",
"@types/node": "^14.14.25",
"@types/uuid": "^8.3.0",
"@types/ws": "^7.2.7",
"animejs": "^3.2.0",
"@crimata/nodeaudio": "0.0.0",
"axios": "^0.21.1",
"core-js": "^3.6.5",
"electron-is-dev": "^2.0.0",
"base64-arraybuffer": "^1.0.1",
"electron-store": "^8.0.0",
"electron-updater": "^4.3.8",
"mitt": "^2.1.0",
"naudiodon": "^2.3.2",
"node-record-lpcm16": "^1.0.1",
"update-electron-app": "^2.0.1",
"uuid": "^8.3.2",
"vue": "^3.0.0-0",
"vue-router": "^4.0.0-0",
"vuex": "^4.0.0-0",
"ws": "^7.3.1"
},
"devDependencies": {
"@types/axios": "^0.14.0",
"@types/electron-devtools-installer": "^2.2.0",
"@types/jest": "^24.0.19",
"@typescript-eslint/eslint-plugin": "^2.33.0",
"@typescript-eslint/parser": "^2.33.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-plugin-unit-jest": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0-0",
"@vue/eslint-config-typescript": "^5.0.2",
"@vue/test-utils": "^2.0.0-0",
"@wasm-tool/wasm-pack-plugin": "^1.3.1",
"electron": "^9.0.0",
"electron-devtools-installer": "^3.1.0",
"electron-log": "^4.3.4",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0-0",
"lint-staged": "^9.5.0",
"node-sass": "^4.12.0",
"optimize-wasm-webpack-plugin": "^1.0.12",
"sass-loader": "^8.0.2",
"spectron": "11.0.0",
"typescript": "~3.9.3",
"vue-cli-plugin-electron-builder": "~2.0.0-rc.6",
"vue-jest": "^5.0.0-0"
},
"vue": {
"lintOnSave": false,
"pluginOptions": {
"electronBuilder": {
"mainProcessFile": "./src/init.ts",
"rendererProcessFile": "./src/render/main.ts",
"preload": "./src/render/preload.ts",
"builderOptions": {
"appId": "com.crimata.ElectronUpdaterApp",
"artifactName": "${productName}-${version}.${ext}",
"publish": {
"provider": "generic",
"url": "https://gitlab.com/api/v4/projects/25637892/jobs/artifacts/main/raw/dist_electron?job=build"
}
}
}
}
},
"gitHooks": {
"pre-commit": "lint-staged"
},
"lint-staged": {
"*.{js,jsx,vue,ts,tsx}": [
"vue-cli-service lint",
"git add"
]
},
"productName": "crimata-messenger",
"repository": {
"type": "git",
"url": "https://gitlab.com/crimata/electron-app.git",
"release": "latest"
"electron-notarize": "^1.1.1",
"electron-osx-sign": "^0.6.0"
}
}

View file

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

41
src/account.js Normal file
View file

@ -0,0 +1,41 @@
const { ipcMain } = require("electron");
const { post } = require("./api");
const store = require("./utils/store");
const { launchSession, endSession } = require("./session");
const { backgroundMitt, ipcEmit } = require("./utils/emitter");
async function auth(_e, creds)
{
const { error, data } = await post("/auth", creds);
if (error)
{
return data;
}
store.set("account", data);
ipcEmit("account", data);
launchSession(data);
};
function logout(_e, reason)
{
store.delete("account");
ipcEmit("account", false, reason);
endSession();
};
function initAccount()
{
ipcMain.handle("auth", auth);
ipcMain.on("logout", logout);
const account = store.get("account");
if (account) launchSession(account);
}
backgroundMitt.on("logout", (reason) => logout(null, reason));
exports.initAccount = initAccount;

View file

@ -1,95 +0,0 @@
import { postAuth, postLogin, postLogout } from "@/api/account";
import { endSession, launchSession } from "@/session";
import { getToken, setToken, setProfile, getProfile, clearStore } from "./store";
import { parseAuthRes } from "./auth";
import { ipcEmit } from "@/composables/useEmitter";
export const accountAuth = async (): Promise<Error | AuthState> => {
/* attempt to get a login token from the store */
const token = getToken();
/* try to login with it, returns platform secret and new token on success */
if (token) {
try {
const res = await postAuth(token);
const parsed = parseAuthRes(res);
setToken(parsed.token)
setProfile(parsed.profile);
return {
profile: parsed.profile,
token: parsed.token
};
} catch(e) {
console.log('[ACCOUNT]', e);
clearStore();
throw(new Error('Failed to authenticate.'));
}
} else {
throw(new Error('Unable to authenticate.'));
}
};
export const accountLogin: IpcHandlerCallback<AccountCredentials, Profile> = async (payload) => {
const account = payload as AccountCredentials;
try {
// attempt login with email password
const res = await postLogin(account.email, account.password);
const parsed = parseAuthRes(res);
// save jwt token and profile
setToken(parsed.token);
setProfile(parsed.profile);
// launch session
launchSession(parsed.token);
// return profile to renderer
return parsed.profile;
} catch(e) {
clearStore();
throw e;
}
}
export const accountLogout = async (): Promise<Error | void> => {
try {
// post logout to backend
await postLogout();
// remove key and crimataId
clearStore();
// kill crimata platform session
endSession();
return;
} catch(e) {
console.log('[ACCOUNT]', e);
return (new Error('Failed to logout. Please try again.'));
}
}
export const updateAppState = (): void => {
const profile = getProfile();
ipcEmit("set-profile", profile);
// ipcEmit('messages') etc
}

38
src/api.js Normal file
View file

@ -0,0 +1,38 @@
const axios = require('axios').default;
const config = require("./config");
async function post(route, body)
{
let error;
let data;
try
{
const res = await axios.post(config.API + route, body);
data = res.data;
error = false;
}
catch (e)
{
if (e.response)
{
data = e.response.data;
}
else if (e.request)
{
data = "Can't connect to the server."
}
else
{
data = "Unknown error occured."
}
error = true;
}
return { error, data };
}
exports.post = post;

View file

@ -1,31 +0,0 @@
import useHttp from "@/composables/useHttp";
import axios from "axios";
import {config} from "@/config";
const { post } = useHttp();
export const postAuth = async (token: string) => (
await axios({
url: config.BUSINESS_URL + config.BUSINESS_PREFIX + '/account/authenticate',
headers: {
Cookie: `crimataCookie=${token}`
},
method: 'POST',
})
);
export const postLogin = async (email: string, password: string) => (
await post('/account/login', { email, password })
);
export const postLogout =
async (): Promise<null | Error> => (await post('/account/logout'));

BIN
src/assets/icon.icns Normal file

Binary file not shown.

BIN
src/assets/tray/000.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
src/assets/tray/000@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

BIN
src/assets/tray/001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
src/assets/tray/001@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src/assets/tray/010.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
src/assets/tray/010@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
src/assets/tray/011.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
src/assets/tray/011@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
src/assets/tray/100.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
src/assets/tray/100@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
src/assets/tray/101.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
src/assets/tray/101@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
src/assets/tray/110.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
src/assets/tray/110@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
src/assets/tray/111.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
src/assets/tray/111@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

134
src/audio.js Normal file
View file

@ -0,0 +1,134 @@
const nodeAudio = require("@crimata/nodeaudio");
const { globalShortcut, ipcMain } = require("electron");
const { sendMessage } = require("./io");
const { updateTray } = require("./tray");
const { encode, decode } = require("./codec");
const { backgroundMitt, ipcEmit } = require("./utils/emitter");
let inputDevice;
let outputDevice;
let setWriteId;
let setStreamsId;
let autoStopId; /* keep track of recording time */
let playbackId; /* UID of the message being played */
const streamState = { rec: false, pb: false };
const /** @type {Int16Array[]} */ chunks = [];
backgroundMitt.on("data", (int16Arr) => {
if (streamState.rec) chunks.push(int16Arr);
});
backgroundMitt.on("write", (int16Arr) => {
clearTimeout(setWriteId);
setWriteId = setTimeout(() => {
setPlaybackStatus(false);
}, 500);
});
function setStreams()
{
const defaultInput = nodeAudio.core.GetDefaultInputDevice();
const defaultOutput = nodeAudio.core.GetDefaultOutputDevice();
if (inputDevice !== defaultInput)
{
inputDevice = defaultInput;
nodeAudio.core.CloseInputStream(inputDevice);
nodeAudio.core.OpenInputStream(inputDevice);
}
if (outputDevice !== defaultOutput)
{
outputDevice = defaultOutput;
nodeAudio.core.CloseOutputStream(outputDevice);
nodeAudio.core.OpenOutputStream(outputDevice);
}
}
function startRecording()
{
setRecordingStatus(true);
/* 15s recording time limit */
autoStopId = setTimeout(stopRecording, 15000);
}
function stopRecording()
{
if (autoStopId)
{
clearTimeout(autoStopId);
}
setRecordingStatus(false);
sendMessage({
category: "audio",
text: null,
blob: encode(nodeAudio.utils.mergeChunks(chunks).buffer)
});
chunks.length = 0;
}
function initAudio()
{
nodeAudio.core.Initialize(backgroundMitt.emit.bind(backgroundMitt));
setStreamsId = setInterval(setStreams, 2000);
const res = globalShortcut.register('CommandOrControl+Return', () => {
streamState.rec ? stopRecording() : startRecording();
});
if (!res) throw new Error("Failed to register recording shortcut");
}
function playback(base64String, id)
{
/* terminate any current playback */
nodeAudio.core.CancelPlayback();
ipcEmit("playback", playbackId, false);
playbackId = id;
setPlaybackStatus(true);
nodeAudio.core.WriteToOutputStream(decode(base64String));
}
function terminateAudio()
{
globalShortcut.unregisterAll();
/* Only terminate PA if initialized */
if (setStreamsId)
{
clearInterval(setStreamsId);
nodeAudio.core.Terminate();
}
}
function setRecordingStatus(status)
{
streamState.rec = status;
ipcEmit("record", status);
updateTray("recording", streamState.rec);
}
function setPlaybackStatus(status)
{
console.log("Setting playback status: ", status);
streamState.pb = status;
ipcEmit("playback", playbackId, status);
updateTray("playback", status);
}
exports.initAudio = initAudio;
exports.terminateAudio = terminateAudio;
exports.playback = playback;
exports.streamState = streamState;

View file

@ -1,187 +0,0 @@
/* eslint @typescript-eslint/no-var-requires: "off" */
"use strict";
// where the audio goes
let buffer: ArrayBuffer[] = [];
// place audio data in buffer
export const collect: IpcListenerCallback<ArrayBuffer> = (chunk) => {
if (chunk) buffer.push(chunk);
}
// return audio and clear buffer
export const flush = async () => {
const bufferCopy = buffer;
buffer = [];
return bufferCopy;
}
// import { backgroundMitt } from '@/modules/emitter';
// const portAudio = require('naudiodon');
// // Audio in and out stream objects.
// let ai: typeof portAudio.AudioIO | boolean = false;
// let ao: typeof portAudio.AudioIO | boolean = false;
// // Whether activly recording.
// let record = false;
// const audioContainer = {
// input: '',
// }
// const audioOptions = {
// channelCount: 1,
// sampleFormat: 16,
// sampleRate: 16000,
// deviceId: -1,
// closeOnError: false,
// }
// export const toggleRecord = (): void => { record = !record };
// export const fetchAudioInput = (): Promise<Error | string> => (
// new Promise((resolve, reject) => {
// try {
// resolve(audioContainer.input);
// toggleRecord();
// } catch (e) {
// reject(new Error('Failed to fetch the audio.'))
// }
// })
// )
// // Main audio function run by run.ts module.
// export function initAudioIO(): void {
// console.log("AUDIO:Starting io streams.")
// if (!ai) {
// // Initialize and start input stream.
// ai = new portAudio.AudioIO({ inOptions: audioOptions });
// ai.setEncoding("hex");
// ai.start();
// // On each data chunk...
// ai.on('data', (chunk: string) => {
// // If recording, we capture the data.
// if (record) {
// console.log('AUDIO:Recording...')
// audioContainer.input += chunk;
// }
// // Else, we don't capture and also clear audioContainer.
// else {
// if (audioContainer.input.length) {
// audioContainer.input = "";
// }
// }
// });
// }
// if (!ao) {
// // Initialize and start input stream.
// ao = new portAudio.AudioIO({ outOptions: audioOptions });
// ao.start();
// }
// }
// // ---Audio playback--------------------------------------------
// // Split Buffer into an array of len-sized Buffers.
// function bufSplit(buf: Buffer, len: number): Array<Buffer> {
// const chunks = [];
// let i = 0;
// let L = len;
// while(i < buf.byteLength) {
// chunks.push(buf.slice(i, L));
// i = L;
// L += len;
// }
// return chunks;
// }
// // Audio playback.
// export function play(input: string): void {
// // Format the audio.
// const audio = bufSplit(
// Buffer.from(input as string, 'hex'),
// 8192
// );
// // Called on end of write.
// const callback = () => {
// // We stop audio playback anim.
// backgroundMitt.emit('ipc-renderer', {
// endpoint: 'stop-playback-anim'
// });
// }
// write();
// // Iterate through audio array and write buffers to portAudio writable.
// function write() {
// let chunk: Buffer;
// let ok = true;
// let i = 0;
// do {
// chunk = audio[i];
// if (i === audio.length - 1) {
// // write last chunk.
// ao.write(chunk, null, callback);
// } else {
// // check for backpreassure.
// ok = ao.write(chunk, null);
// }
// i++;
// } while (i < audio.length && ok);
// if (i < audio.length) {
// // Had to stop early!
// // Write some more once it drains.
// ao.once('drain', write);
// }
// }
// }
// // -------------------------------------------------------------
// // Get's called on window close.
// export async function stopStream() {
// console.log("AUDIO:Stopping audio stream.")
// if (ai) {
// try {
// await ai.quit()
// } catch(e){
// console.log('AUDIO: Failed to shutdown audio input.');
// throw e;
// }
// }
// if (ao) {
// try {
// await ao.quit()
// } catch(e){
// console.log('AUDIO: Failed to shutdown audio output.');
// throw e;
// }
// }
// }

View file

@ -1,13 +0,0 @@
export const parseAuthRes = (authRes: any) => {
const token = authRes.headers['set-cookie'][0].split(";")[0].split("=")[1] as string;
const profile = authRes.data as Profile;
return {
token,
profile
}
};

13
src/codec.js Normal file
View file

@ -0,0 +1,13 @@
const { ipcMain } = require("electron");
const { encode, decode } = require("base64-arraybuffer");
ipcMain.handle("encode", (_e, arrayBuffer) => {
return encode(arrayBuffer);
});
ipcMain.handle("decode", (_e, b64string) => {
return decode(b64string);
});
exports.encode = encode;
exports.decode = decode;

View file

@ -1,31 +0,0 @@
const { autoUpdater } = require('electron-updater');
let win: boolean;
// Listen for window creation.
backgroundMitt.on('window-active', (state: boolean) => {
win = state;
});
// Auto updating.
autoUpdater.requestHeaders = { 'PRIVATE-TOKEN': 'mvvgWYwWnot4bisiQMh_' }
autoUpdater.on('update-available', (info: any) => {
console.log(`Update available: ${info.version}`)
})
autoUpdater.on('update-downloaded', (info: any) => {
const updateDialog = {
type: 'info',
buttons: ['Restart', 'Later'],
title: 'Application Update',
message: info.version,
detail: 'A new version has been downloaded. Restart the application to apply the updates.'
}
dialog.showMessageBox(updateDialog).then((returnValue) => {
if (returnValue.response === 0) autoUpdater.quitAndInstall()
})
})

View file

@ -1,16 +0,0 @@
// Backend emitter
//
const EventEmitter = require('events');
class BackgroundMitt extends EventEmitter { }
export const backgroundMitt = new BackgroundMitt();
export const ipcEmit = <T>(channel: string, payload: T) => {
backgroundMitt.emit('ipc-renderer', {
channel,
payload
});
};

View file

@ -1,51 +0,0 @@
import axios, { AxiosRequestConfig } from 'axios';
import {config} from "@/config";
const baseURL = config.BUSINESS_URL + config.BUSINESS_PREFIX;
interface Request {
endpoint: string;
query?: Record<string, any>;
config?: Record<string, any>;
}
const makeQuery = (reqQuery: Record<string, any>) => {
let result = '';
result = '?' + Object.entries(reqQuery)
.map(([ key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
return result;
};
export default function useHttp() {
const api = axios.create({
baseURL,
withCredentials: true,
});
const post = async (endpoint: string, payload?: Record<string, any>): Promise<any> => (
await api.post(endpoint, payload)
)
const get = async (req: Request) => {
if (req.query) {
req.endpoint += makeQuery(req.query);
}
const res = await api.get(req.endpoint, req.config);
return res;
};
return {
get, post
}
}

View file

@ -1,82 +0,0 @@
import { ipcMain, IpcMainInvokeEvent, IpcMainEvent } from "electron";
export class IpcHandler<InputParam, ReturnType> implements IIpcHandler<InputParam, ReturnType> {
readonly channel: string;
readonly _handlerCallback: IpcHandlerCallback<InputParam, ReturnType>;
constructor(options: {
channel: string;
handlerCallback: IpcHandlerCallback<InputParam, ReturnType>;
}) {
this.channel = options.channel;
this._handlerCallback = options.handlerCallback;
}
handle() {
this.remove();
ipcMain.handle(this.channel, this._onInvoke);
}
remove() {
ipcMain.removeHandler(this.channel);
}
private _onInvoke = (_e: IpcMainInvokeEvent, payload?: string | null): Promise<Error | ReturnType> => {
return new Promise(async (resolve, reject) => {
console.log(`[IPC] Handle:${this.channel}`);
try {
const params = payload ? JSON.parse(payload) : null;
const res = await this._handlerCallback(params);
resolve(res as unknown as ReturnType);
} catch(e) {
console.log(`[IPC] Error:${this.channel}`);
reject(e);
}
});
}
}
export class IpcListener<InputParam> implements IIpcListener<InputParam> {
readonly channel: string;
readonly _listenerCallback: IpcListenerCallback<InputParam>;
constructor(options: {
channel: string;
listenerCallback: IpcListenerCallback<InputParam>;
}) {
this.channel = options.channel;
this._listenerCallback = options.listenerCallback;
}
listen() {
this.remove();
ipcMain.on(this.channel, this._onPost);
}
remove() {
ipcMain.removeAllListeners(this.channel);
}
private _onPost = (_e: IpcMainEvent, payload?: string | null): void => {
console.log(`[IPC] Post: ${this.channel}`);
const params = payload ? JSON.parse(payload) : null;
this._listenerCallback(params);
}
}

View file

@ -1,36 +0,0 @@
export default class Canvas {
messages: Message[];
/* seed canvas with messages on init */
constructor(messages: Message[]) {
this.messages = messages;
ipcEmit("seed-view", this.messages);
}
/* add a new message to the canvas */
add(message: Message) {
this.messages.push(message);
ipcEmit("update-view", message);
}
/* update an existing message */
update(message: Message) {
/* get the target message */
let target_message = this.messages.filter((m: Message) => {
return m.uid = message.uid;
})[0];
/* replace the target message */
if (target_message) {
target_message = message;
ipcEmit("update-view", message);
}
}
}

View file

@ -1,13 +0,0 @@
import {config} from "@/config";
import fs from 'fs';
export const saveToJson = (fileName: string, data: any) => {
fs.writeFile(config.configPath + fileName, JSON.stringify(data), (err) => {
if (err) {
console.log("Error when saving to json.")
}
})
}

View file

@ -1,89 +0,0 @@
"use strict";
import WebSocket from 'ws';
const _connectionCheckTimeout = 4000;
const _reconnectTimeout = 1000;
let _connectionCheckInterval: ReturnType<typeof setTimeout>;
export default function useWebSockets(
messageCallback: (message: string) => void,
connectionStatusCallback: (alive: boolean) => void,
) {
let socket: WebSocket;
const send = async (data: Record<string, any>): Promise<boolean> => {
return new Promise((resolve, reject) => {
if (socket) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(data));
resolve(true);
}
}
reject(false);
});
}
const connect = (socketUrl: string, secret: string) => {
// avoid setting multiple interval;
if (_connectionCheckInterval) clearInterval(_connectionCheckInterval);
/* create a new socket */
socket = new WebSocket(socketUrl);
/* add event listeners */
socket.on("open", () => {
socket.send(JSON.stringify({key: secret}));
// ping server
_connectionCheckInterval = setInterval(() => {
socket.ping(null, true, (e: Error) => {
if (e) {
socket.close();
connectionStatusCallback(false);
setTimeout(() => connect(socketUrl, secret), _reconnectTimeout);
}
});
}, _connectionCheckTimeout);
});
socket.on("message", (event: WebSocket.MessageEvent) => {
console.log("message received", event);
messageCallback(event.toString())
});
socket.on("close", (event: WebSocket.CloseEvent) => {
connectionStatusCallback(false);
clearInterval(_connectionCheckInterval);
if (!event.wasClean) {
setTimeout(() => connect(socketUrl, secret), _reconnectTimeout);
}
});
socket.on("pong", () => connectionStatusCallback(true));
}
const close = () => {
if (socket) {
socket.close();
}
}
return {
connect,
send,
close
};
}

11
src/config.js Normal file
View file

@ -0,0 +1,11 @@
const { app } = require("electron");
const DOMAIN = "crimata.com";
const prod = !process.defaultApp;
// const prod = true;
module.exports = {
PLATFORM: prod ? `https://app.${DOMAIN}` : `http://localhost:8760`,
API: prod ? `https://${DOMAIN}/api` : `http://localhost:8761`
}

View file

@ -1,17 +0,0 @@
import { app } from "electron";
const env = process.env;
const PLATFORM_PORT = env.PLATFORM_PORT || 8760;
const PLATFORM_IP = env.PLATFORM_IP || 'http://127.0.0.1';
const BUSINESS_PORT = env.BUSINESS_PORT || 3000;
const BUSINESS_IP = env.BUSINESS_IP || 'http://127.0.0.1';
export const config = {
PLATFORM_URL: `${PLATFORM_IP}:${PLATFORM_PORT}`,
BUSINESS_URL: `${BUSINESS_IP}:${BUSINESS_PORT}`,
BUSINESS_PREFIX: '/api',
configPath: app.getPath('userData')
}

View file

@ -1,52 +0,0 @@
/**
* Entry point for Crimata electron app.
* "Look on my Works, ye Mighty, and despair!"
*/
"use strict";
import { app, protocol } from "electron";
import createWindow from "./window";
import main from "./main";
import { backgroundMitt } from '@/composables/useEmitter';
console.log('Starting Crimata electron app.');
// Scheme must be registered before the app is ready
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { secure: true, standard: true } }
]);
const isDev = require('electron-is-dev');
let win: boolean;
// Listen for window creation.
backgroundMitt.on('window-active', (state: boolean) => {
win = state;
});
/* Start main process on ready */
app.on("ready", async () => {
await main();
});
// Must keep to ensure app doesn't quit on close.
app.on("before-quit", async () => {
});
// Must keep to ensure app doesn't quit on close.
app.on("window-all-closed", () => {
});
// When user clicks app icon (re-open)
app.on("activate", () => {
if (!win) createWindow();
});
// Exit cleanly on request from parent process in development mode.
if (isDev) {
process.on("SIGTERM", () => {
app.quit();
});
}

69
src/io.js Normal file
View file

@ -0,0 +1,69 @@
const WebSocket = require("ws");
const { ipcMain } = require("electron");
const config = require("./config");
const { updateTray } = require("./tray");
const { backgroundMitt, ipcEmit } = require("./utils/emitter");
let connection = null;
let pingId;
let reconnectId;
function connectToPlatform(account)
{
if (pingId) clearInterval(pingId);
connection = new WebSocket(`${config.PLATFORM}/${account}`)
.on("open", () => pingId = setInterval(() => connection.ping(null, true), 1000))
.on("pong", () => setConnectionStatus(0))
.on("error", () => {}) /** keep silent on error */
.on("message", (payload) => backgroundMitt.emit("message", JSON.parse(payload)))
.on("close", (_code, reason) => {
setConnectionStatus(1);
if (reason)
{
if (reason == "unauthorized")
{
backgroundMitt.emit("logout", reason);
}
return;
}
reconnectId = setTimeout(() => connectToPlatform(account), 500);
});
}
function sendMessage(message)
{
if (connection.readyState === WebSocket.OPEN) {
connection.send(JSON.stringify(message));
} else console.log("Failed to send message");
}
function disconnectFromPlatform()
{
clearTimeout(reconnectId);
if (connection.open) connection.close(1000, "logout");
}
function setConnectionStatus(status)
{
updateTray("disconnect", status);
ipcEmit("ws", status);
}
ipcMain.on("message", (_e, message) => sendMessage(message));
exports.connectToPlatform = connectToPlatform;
exports.sendMessage = sendMessage;
exports.disconnectFromPlatform = disconnectFromPlatform;

View file

@ -1,28 +0,0 @@
"use strict";
import { accountLogin, accountLogout, accountProfile } from "@/account";
import { IpcHandler } from "@/composables/useIpcMain";
import { flush } from "@/audio";
const LOGIN_CHANNEL = "invoke-account-login";
const LOGOUT_CHANNEL = "invoke-account-logout";
const GET_AUDIO_CHANNEL = "invoke-audio-flush";
export const loginHandler = new IpcHandler({
channel: LOGIN_CHANNEL,
handlerCallback: accountLogin
});
export const logoutHandler = new IpcHandler({
channel: LOGOUT_CHANNEL,
handlerCallback: accountLogout
});
export const getAudioHandler = new IpcHandler({
channel: GET_AUDIO_CHANNEL,
handlerCallback: flush
});

View file

@ -1,33 +0,0 @@
"use strict";
import * as handlers from "./handlers";
import * as listeners from "./listeners";
const ipcHandlers: IPCHandlers = {};
const ipcListeners: IPCListeners = {};
const _initHandlers = (): void => {
for (const [key, handler] of Object.entries(handlers)) {
if (!(key in ipcHandlers)) {
ipcHandlers[key] = handler;
handler.handle();
}
}
};
const _initListeners = (): void => {
for (const [key, listener] of Object.entries(listeners)) {
if (!(key in ipcListeners)) {
ipcListeners[key] = listener;
listener.listen();
}
}
};
export default function initIpcMain(): void {
_initHandlers();
_initListeners();
}

View file

@ -1,24 +0,0 @@
import { IpcListener } from "@/composables/useIpcMain"
import { sendMessage } from '@/session';
import { collect } from "@/audio";
import { updateAppState } from "@/account";
const CLIENT_MESSAGE_CHANNEL = "post-session-send"
const GET_AUDIO_CHANNEL = "post-audio-collect";
const APP_MOUNT_CHANNEL = "post-app-mount";
export const messageListener = new IpcListener<Message>({
channel: CLIENT_MESSAGE_CHANNEL,
listenerCallback: sendMessage
});
export const audioChunkListener = new IpcListener<ArrayBuffer>({
channel: GET_AUDIO_CHANNEL,
listenerCallback: collect
});
export const appMountListener = new IpcListener<null>({
channel: APP_MOUNT_CHANNEL,
listenerCallback: updateAppState
});

28
src/main.js Normal file
View file

@ -0,0 +1,28 @@
const { app, protocol, ipcMain, nativeTheme } = require("electron");
const store = require("./utils/store");
const { initTray } = require("./tray");
const { createWin } = require("./window");
const { initAccount } = require("./account");
const { terminateAudio } = require("./audio");
// Assert a light theme
nativeTheme.themeSource = "light";
app.on("ready", () =>
{
initTray();
createWin();
initAccount();
});
app.on("will-quit", terminateAudio);
// When user clicks app icon (re-open)
app.on("activate", createWin);
// Prevents app from quitting on window close event
app.on('window-all-closed', (e) => e.preventDefault());

View file

@ -1,39 +0,0 @@
/**
* Where the background logic really begins, gets called by app.onReady().
*
* Handles authentication. If profile is set, we launch a session, which consis
* of opening a connection with the platform, initializing the audio streams.
*
* The session is primarily an interface between the frontend and the platform,
* relaying messages from one to the other.
*
*/
import initIpcMain from "@/ipc/index";
import { accountAuth, updateAppState } from "./account";
import { launchSession } from "./session";
import createWindow from "./window";
let authState: AuthState | null;
export default async function main() {
/* initiate controls for frontend to use when needed */
initIpcMain();
/* launch browser window */
await createWindow();
try {
authState = await accountAuth() as AuthState;
} catch(e) {
console.log('AUTH:', e);
authState = null;
} finally {
if (authState) {
launchSession(authState.token as string);
}
updateAppState();
}
}

View file

@ -1,76 +0,0 @@
<template>
<!-- Only render when profile has been set -->
<main id="app" v-if="authComplete">
<Header />
<Messenger
v-if="profile"
/>
<!-- Main Components
-->
<Login v-else />
</main>
<!-- Show spash screen if ready is false -->
<Splash v-else />
</template>
<script lang="ts">
import { defineComponent, onMounted } from "vue";
import { IpcRendererEvent } from "electron";
import Splash from "@/render/components/splash.vue";
import Messenger from "@/render/components/messenger.vue";
import Login from "@/render/components/login.vue";
import Header from "@/render/components/header.vue";
import { profile, authComplete } from "@/render/composables/useProfile";
import { postAppMount } from "@/render/ipc";
export default defineComponent({
components: {
Splash,
Messenger,
Login,
Header
},
setup() {
onMounted(() => postAppMount());
return {
authComplete,
profile
}
}
})
</script>
<style lang="scss">
html, body {
margin: 0;
padding: 0;
}
#app {
font-family: "SF Pro Text";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100vh;
width: 100vw;
border-radius: 15px;
}
</style>

View file

@ -0,0 +1,12 @@
<svg id="logo" xmlns="http://www.w3.org/2000/svg" width="24.87" height="23.001" viewBox="0 0 24.87 23.001">
<g id="Group_33" data-name="Group 33">
<g id="Group_32" data-name="Group 32" transform="translate(0 0)">
<g id="Group_31" data-name="Group 31" transform="translate(0 11.948)">
<circle id="Ellipse_50" data-name="Ellipse 50" cx="5.527" cy="5.527" r="5.527" transform="translate(0 0)" fill="#383838"/>
<circle id="Ellipse_51" data-name="Ellipse 51" cx="5.527" cy="5.527" r="5.527" transform="translate(13.817 0)" fill="#383838"/>
</g>
<circle id="Ellipse_52" data-name="Ellipse 52" cx="5.527" cy="5.527" r="5.527" transform="translate(6.898)" fill="#383838"/>
<path id="Path_150" data-name="Path 150" d="M36.27,83.67" transform="translate(-30.733 -66.196)" fill="#ff0"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="5" viewBox="0 0 25 5">
<g id="Group_611" data-name="Group 611" transform="translate(-4032 499)">
<circle id="Ellipse_71" data-name="Ellipse 71" cx="2.5" cy="2.5" r="2.5" transform="translate(4032 -499)" fill="#9b9b9b"/>
<circle id="Ellipse_72" data-name="Ellipse 72" cx="2.5" cy="2.5" r="2.5" transform="translate(4042 -499)" fill="#9b9b9b"/>
<circle id="Ellipse_73" data-name="Ellipse 73" cx="2.5" cy="2.5" r="2.5" transform="translate(4052 -499)" fill="#9b9b9b"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View file

@ -0,0 +1,44 @@
import Login from "./login.js";
import Splash from "./splash.js";
import Header from "./header.js";
import Messenger from "./messenger.js";
const account = Vue.ref(null);
const App =
{
components: {
Splash,
Header,
Messenger,
Login
},
setup()
{
return { account };
},
template: `
<div id="app" v-if="account !== null">
<Header />
<Messenger v-if="account"/>
<Login v-else />
</div>
<Splash v-else />`
}
window.mainApi.on("account", (value, reason) => {
if (reason) alert(reason);
account.value = value;
});
export default App;
export { account };
// Is called when window is about to close or reload
window.onbeforeunload = () => console.log("beforeunload");

View file

@ -0,0 +1,48 @@
const Bubble =
{
props: ["category", "text", "html", "blob", "modifier", "child"],
setup(props)
{
const getUrl = () => {
return `data:${props.category};base64,${props.blob}`;
}
Vue.onMounted(() => {
const el = document.getElementById("messenger");
setTimeout(() => {
el.dispatchEvent(new CustomEvent('adjust-scroll'));
}, 100);
});
if (!props.text) props.text = "...";
return { getUrl };
},
template: `
<div :class="'bubble ' + modifier + '-bubble ' + modifier +'-'+ child">
<small class="bubble-notify" v-if="modifier=='ai'"></small>
<p v-if="category=='text'">{{ text }}</p>
<p v-else-if="category=='audio'">{{ text }}</p>
<div v-else-if="category=='html'" v-html="html"></div>
<img v-else-if="category == 'image'" :src="getUrl()" />
<a v-else :download="text" :href="getUrl()">{{ text }}</a>
</div>`
};
export default Bubble;
// TODO: should include other styling/actions for audio bubbles

View file

@ -1,331 +0,0 @@
<template>
<div :class="`${type}-message`">
<!-- message bubble -->
<div :class="`${type}-${child}-bubble`">
<div class="notification-dot"/>
<div class="error-dot"/>
{{ text }}
</div>
<!-- message context -->
<span class="context">
<div :class="`${type}-icon`"/>
{{ context }}
</span>
</div>
</template>
<script lang='ts'>
import { defineComponent, ref, onMounted } from 'vue';
export default defineComponent({
name: "Bubble",
props: ["text", "context", "type", "position"],
setup() {
const seen = ref(false);
/* initialize seen state */
if (document.visibilityState === "visible") {
seen.value = true;
} else seen.value = false;
onMounted(() => {
if(seen.value)
document.addEventListener("visibilitychange", () => {
seen.value = true
});
});
return {
seen
};
}
})
</script>
<style lang="scss" scoped>
.message {
width: 100vw;
display: flex;
flex-direction: column;
padding-top: 9px;
padding-bottom: 9px;
}
.message:first-child {
margin-top: 55px;
}
.message:last-child {
margin-bottom: 18px;
}
.sf-message {
@extend .message;
justify-content: flex-end;
}
.ai-message, .fr-message {
@extend .message;
justify-content: flex-start;
}
.bubble {
position: relative;
max-width: 66vw;
font-family: "SF Pro Text";
font-size: 14px;
padding: 10px;
border-radius: 18px;
}
.sf-bubble {
@extend .bubble;
background-color: #58c4fd;
color: white;
}
.firstChildMessage {
padding-bottom: 2px;
}
.middleChildMessage {
padding-top: 2px;
padding-bottom: 2px;
}
.lastChildMessage {
padding-top: 2px;
}
#aiMessage {
align-items: flex-start;
}
#sfMessage {
align-items: flex-end;
}
#frMessage {
align-items: flex-start;
}
.messageBox {
position: relative;
display: flex;
flex-direction: column;
// Animate on render.
animation-name: appear;
animation-duration: 0.25s;
}
#aiMessageBox {
margin-left: 20px;
align-items: flex-start;
}
#sfMessageBox {
margin-right: 20px;
align-items: flex-end;
}
#frMessageBox {
margin-left: 20px;
align-items: flex-start;
}
.bubble {
position: relative;
max-width: 66vw;
display: flex;
flex-direction: column;
font-family: "SF Pro Text";
font-size: 14px;
padding: 10px;
border-radius: 18px;
}
@keyframes appear {
10% {
transform: scale(0.3);
}
100% {
transform: scale(1);
}
}
#aiBubble {
background-color: white;
}
#sfBubble {
background-color: #58c4fd;
color: white;
}
#frBubble {
background-color: white;
}
.sf-firstChildBubble {
border-bottom-right-radius: 9px;
}
.sf-middleChildBubble {
border-top-right-radius: 9px;
border-bottom-right-radius: 9px;
}
.sf-lastChildBubble {
border-top-right-radius: 9px;
}
.ai-firstChildBubble, .fr-firstChildBubble {
border-bottom-left-radius: 9px;
}
.ai-middleChildBubble, .fr-middleChildBubble {
border-top-left-radius: 9px;
border-bottom-left-radius: 9px;
}
.ai-lastChildBubble, .fr-lastChildBubble {
border-top-left-radius: 9px;
}
.notify {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #58D9FF;
top: -5px;
left: -5px;
border: 2px solid #EBEBEB;
transform: scale(0);
animation-name: notify-anim;
animation-duration: 5s;
}
@keyframes notify-anim {
0%, 90% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
.context {
position: relative;
display: flex;
align-items: center;
font-family: "SF Compact Display";
font-size: 12px;
font-weight: bold;
margin-top: 5px;
}
.photo {
display: flex;
justify-content: center;
align-items: center;
margin-right: 5px;
width: 30px;
height: 30px;
font-size: 14px;
background-color: white;
border-radius: 15px;
}
// Apply for audio message playback.
.playing {
animation-name: circle1;
animation-duration: 2s;
animation-iteration-count: infinite;
}
@keyframes circle1 {
0%,
100% {
box-shadow: 0 0 0 0.4em rgba(195, 195, 195, 0.4), 0 0 0 0.25em rgba(195, 195, 195, 0.15);
}
25% {
box-shadow: 0 0 0 0.15em rgba(195, 195, 195, 0.15), 0 0 0 0.4em rgba(195, 195, 195, 0.3);
}
50% {
box-shadow: 0 0 0 0.55em rgba(195, 195, 195, 0.55), 0 0 0 0.15em rgba(195, 195, 195, 0.05);
}
75% {
box-shadow: 0 0 0 0.25em rgba(195, 195, 195, 0.25), 0 0 0 0.55em rgba(195, 195, 195, 0.45);
}
}
.contentLoader {
display: flex;
}
.contentLoaderDot {
width: 5px;
height: 5px;
margin: 2px;
border-radius: 2.5px;
background-color: #357CA2;
}
.questionMark {
font-weight: 900;
color: #357CA2;
}
// .divider {
// width: 100vw;
// display: flex;
// justify-content: center;
// align-items: center;
// font-family: "SF Compact Display";
// font-size: 12px;
// font-weight: bold;
// color: #9B9B9B;
// margin-bottom: 18px;
// }
</style>

View file

@ -0,0 +1,33 @@
const Context =
{
props: ["modifier", "context", "avatar", "id"],
setup(props) {
const playback = Vue.ref(false);
Vue.onMounted(() => {
window.mainApi.on("playback", (id, status) => {
if (id === props.id) {
playback.value = status;
}
});
});
return { playback };
},
template: `
<span :class="'context ' + modifier + '-context'">
<img v-show="avatar" class="context-avatar"
:class="{ audio: playback }"
:src="'data:image/png;base64,' + avatar"
/>
{{ context }}
</span> `
}
export default Context;

View file

@ -0,0 +1,195 @@
const saveLocation = "input_item_position";
const defaultPosition = { x: 15, y: 400 };
//---Dragabble Helper Funcs--------------------------------------
// Calculate distance to nearest side.
function calcSideProximity (elementX, elementLength, winW) {
// Calc right short.
let short = winW - elementX - elementLength;
// See if it's left short.
if (elementX + 20 < winW / 2) {
short = elementX
}
return short
}
// Update elementX or elementY value on window resize
function calcPosition (elementPosition, elementLength, percent, short, win) {
// Is it close to the right/bottom side?
if (percent > 0.75) {
elementPosition = win - short - elementLength;
}
// Is it not close to a side?
if (percent < 0.75 && percent > 0.25) {
elementPosition = win * percent
}
return elementPosition
}
function draggify(elementId, parentId, margin) {
let element;
let parent;
/* only compatible with elements having equal width and height */
let elementLength;
// Cords of inputItem.
const elementX = Vue.ref();
const elementY = Vue.ref();
// Position of inputItem on terms of percentage of window.
let percentX;
let percentY;
// How close inputItem is to closest X or Y side.
let xShort;
let yShort;
//---Reposition Anime-----------------------------------------------
// Move element to target smoothly.
const repositionAnime = (xChange, yChange) => {
const xStep = xChange / 1000;
const yStep = yChange / 1000;
for (let i = 1; i <= 1000; i++) {
setTimeout(() => {
elementX.value += xStep;
elementY.value += yStep;
}, 30) // 60fps
}
}
//---Event Handlers-----------------------------------------------
// Update the position of inputItem on mouse dragging.
const onMouseMove = (e) => {
e.preventDefault();
elementX.value = element.offsetLeft + e.movementX;
elementY.value = element.offsetTop + e.movementY;
}
// Add an event listener for dragging.
const onMouseDown = (e) => {
e.preventDefault();
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
// Update position references when user is done moving targetEl.
const onMouseUp = (e) => {
e.preventDefault();
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
// See if and calculate reposition.
let x = 0; // vector change
let y = 0;
const winW = parent.clientWidth;
const winH = parent.clientHeight;
if (elementX.value < 0) {
x = (elementX.value - margin)*-1
}
if (elementX.value > winW - elementLength) {
const b = winW - elementLength - margin;
x = (elementX.value - b)*-1
}
// Reposition y
if (elementY.value < 0) {
y = (elementY.value - margin)*-1
}
if (elementY.value > winH - elementLength) {
const d = winH - elementLength - margin;
y = (elementY.value - d)*-1
}
// Reposition if needed.
if (x !== 0 || y !== 0) repositionAnime(x, y);
xShort = calcSideProximity(elementX.value, elementLength, winW);
yShort = calcSideProximity(elementY.value, elementLength, winH);
// Update percentages.
percentX = elementX.value / winW;
percentY = elementY.value / winH;
// Save position.
savePosition();
}
// Update position of targetEl on windowResize.
const onWindowResize = (_e) => {
elementX.value = calcPosition(elementX.value, elementLength, percentX, xShort, parent.clientWidth);
elementY.value = calcPosition(elementY.value, elementLength, percentY, yShort, parent.clientHeight);
savePosition();
}
const savePosition = () => {
window.localStorage.setItem(saveLocation, JSON.stringify({
x: elementX.value,
y: elementY.value
}));
}
//---------------------------------------------------------------
Vue.onMounted(() => {
element = document.getElementById(elementId);
parent = document.getElementById(parentId);
elementLength = element.offsetWidth;
// Initialize the positional references.
percentX = elementX.value / parent.clientWidth;
percentY = elementY.value / parent.clientHeight;
xShort = calcSideProximity(elementX.value, elementLength, parent.clientWidth);
yShort = calcSideProximity(elementX.value, elementLength, parent.clientHeight);
// Then, we can listen for window resize (and mousedown).
element.addEventListener("mousedown", onMouseDown);
parent.addEventListener('resize', onWindowResize)
});
// remove event listeners on component dismount.
Vue.onUnmounted(() => {
element.removeEventListener('mousedown', onMouseDown);
parent.removeEventListener('resize', onWindowResize)
parent.removeEventListener('mouseup', onMouseUp)
parent.removeEventListener('mousemove', onMouseMove);
});
// Try loading initPosition, otherwise set default values
let initPosition;
const rawData = window.localStorage.getItem("saveLocation")
rawData ? initPosition = JSON.parse(rawData) : initPosition = defaultPosition;
elementX.value = initPosition.x;
elementY.value = initPosition.y;
return { elementX, elementY };
}
export default draggify;

View file

@ -0,0 +1,24 @@
const onDrop = async (e) => {
const file = e.dataTransfer.items[0].getAsFile();
if (file.size >= 1 * 1000 * 1000) {
alert("File must be under 1MB.");
return;
}
const buffer = await file.arrayBuffer();
const b64String = await window.mainApi.invoke("encode", buffer);
let category = "file";
if (file.type.startsWith("image/")) {
category = "image"
}
window.mainApi.send("message", {
category: category,
text: file.name,
blob: b64String
});
}
export default onDrop;

View file

@ -0,0 +1,20 @@
let el;
const scroll = () => {
if (!el) {
el = document.getElementById("messenger");
window.addEventListener('resize', scroll);
}
el.scrollTo({
top: el.scrollHeight - el.clientHeight,
behavior: 'smooth'
});
}
export default scroll;
// const isBottom = () => {
// if (el) {
// return el.scrollHeight - el.clientHeight <= el.scrollTop + 1;
// }
// }

View file

@ -0,0 +1,84 @@
const inputLength = 230;
const metaKeys = [
"Tab",
"CapsLock",
"Shift",
"Control",
"Alt",
"Meta",
" ",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Enter",
"Backspace",
"Escape"
];
function useText(elementId, parentId, left)
{
let el;
let parent;
const show = Vue.ref(false);
const leftSide = Vue.ref(false);
const calcSide = () => {
parent.clientWidth - left.value < inputLength ? leftSide.value = true : leftSide.value = false;
}
/* handle user typing */
Vue.onMounted(() => {
el = document.getElementById(elementId);
parent = document.getElementById(parentId);
window.addEventListener("keydown", (e) => {
if (!show.value && !metaKeys.includes(e.key) && !e.ctrlKey)
{
show.value = true;
}
else if (show.value && ((el.value.length === 1 && e.key === "Backspace") || e.key === "Escape"))
{
show.value = false;
}
if (e.key === "Enter" && el.value)
{
if (el.value.length >= 250) {
alert("250 character limit")
return;
}
window.mainApi.send("message", {
category: "text",
text: el.value,
blob: null
});
show.value = false;
}
});
calcSide();
});
Vue.watch(left, calcSide);
Vue.watch(show, (c, _p) => {
if (c) {
el.focus();
} else {
el.value = "";
el.blur();
}
});
return { leftSide, show };
}
export default useText;

View file

@ -1,102 +0,0 @@
import { ref } from "vue";
import anime from "animejs";
import { v4 as uuidv4 } from 'uuid';
export function animateTextInput () {
const side = ref("right");
function show () {
const t1 = (side.value === "right") ? 50 : -70;
const t2 = (side.value === "right") ? 110 : -130;
anime({
targets: '#textInput',
opacity: [0, 1],
translateX: [t1, t2],
scale: [0.3, 1],
duration: 500,
easing: 'easeOutExpo',
})
}
function hide () {
const t = (side.value === "right") ? 50 : -80;
anime({
targets: '#textInput',
opacity: [1, 0],
translateX: t,
scale: 0.3,
duration: 500,
easing: 'easeOutExpo',
})
}
function switchSide () {
const t = (side.value === "right") ? -130 : 110;
anime({
targets: '#textInput',
translateX: t,
duration: 500,
easing: 'easeOutExpo',
})
}
return {
side,
show,
hide,
switchSide
};
}
export function animateAudioInput () {
function show () {
anime({
targets: '#recIcon',
opacity: [0, 0.75],
scale: [0.0, 1],
duration: 250,
easing: 'linear',
})
}
function hide () {
anime({
targets: '#recIcon',
opacity: [0.75, 0],
scale: [1, 0],
duration: 250,
easing: 'linear',
})
}
return {
show,
hide
};
}
export function newMessage ({
text=false,
audio=false,
context=false,
uid=uuidv4()
}) {
return {
text: text,
audio: audio,
context: context,
uid: uid
};
}

View file

@ -1,77 +0,0 @@
import { onMounted, onUnmounted, ref, Ref } from "vue";
import { postMessage } from "@/render/ipc";
import { newMessage, animateAudioInput } from "./helpers";
import { invokeReturnAudio, postAudioChunk } from "@/render/ipc";
export default function useAudioInputController (typing: Ref) {
const recording = ref(false);
let mediaRecorder: MediaRecorder;
const { show, hide } = animateAudioInput();
// initialize audio
const conf = {audio: true, video: false}
navigator.mediaDevices.getUserMedia(conf).then((stream: MediaStream) => {
const options = {mimeType: 'audio/webm'};
mediaRecorder = new MediaRecorder(stream, options);
// post any mew audio to backend
mediaRecorder.addEventListener('dataavailable', (e: BlobEvent) => {
e.data.arrayBuffer().then((buff: ArrayBuffer) => {
postAudioChunk(buff);
});
});
// get audio and post new message to backend
mediaRecorder.addEventListener('stop', (_e: Event) => {
invokeReturnAudio().then((audio: ArrayBuffer[] | Error) => {
console.log(audio);
// postMessage(newMessage({audio: audio}));
});
});
});
// start recording on space bar
const record = () => {
console.log("INPT:Capturing audio...")
mediaRecorder.start();
recording.value = true;
show()
}
// stop recording and send on release
const stop = () => {
console.log("INPT:Stopping record.")
mediaRecorder.stop();
recording.value = false;
hide();
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.keyCode == 32 && !typing.value) record();
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.keyCode == 32 && recording.value) stop();
}
//-----------------------------------------------------------
onMounted(() => {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
});
onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
})
return {
recording
}
}

View file

@ -1,134 +0,0 @@
import { Ref, ref, watch, onMounted, onUnmounted } from "vue";
import { postMessage } from "@/render/ipc";
import { newMessage, animateTextInput } from "./helpers";
export default function useTextInputController(elementX: Ref) {
let textInput: HTMLInputElement | null;
const { side, show, hide, switchSide } = animateTextInput();
let firstKey = true;
const typing = ref(false);
// Prep inputItem for typing.
const prepInput = () => {
show()
typing.value = true
}
// Clear and hide inputItem after done typing.
const clearInput = () => {
if (textInput) {
textInput.value = "";
textInput.blur();
}
hide()
firstKey = true;
typing.value = false;
}
// Send a message and clean up after.
const sendMessage = () => {
if (textInput) {
// Send it to the backend for processing.
const message = newMessage({
text: false
});
// postMessage(message);
clearInput()
}
}
// Keys that are capable of opening the text input (numbers and letters).
const isHotKey = (key: number) => {
if (key >= 47 && key <= 91) { // a letter
return true
}
}
//---Callbacks-----------------------------------------------
const onKeyDown = (e: KeyboardEvent) => {
const key = e.keyCode;
if (textInput) {
// Only runs on firstKey.
if (firstKey) {
if (!isHotKey(key)) {
return
}
prepInput()
}
textInput.focus();
// Close input when no text or on ESC.
if ((textInput.value == "") && (!firstKey) && (key === 8)) { // backspace
clearInput()
return
}
if (key === 27) { // escape
clearInput()
return
}
// Close and send on enter.
if (key === 13) {
if (textInput.value) {
sendMessage()
return
}
}
if (firstKey) firstKey = false
}
}
//-----------------------------------------------------------
// Watch parent position and update side.
watch(elementX, (elementX, _previous) => {
const winW = window.innerWidth
// Logic depends on the side we are on.
if (side.value === "right") {
if (winW - elementX < 230) {
switchSide()
side.value = "left"
}
}
else {
if (winW - elementX > 230) {
switchSide()
side.value = "right"
}
}
});
onMounted(() => {
textInput = document.getElementById("textInput") as HTMLInputElement;
window.addEventListener("keydown", onKeyDown);
})
onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown);
});
return {
typing
}
}

View file

@ -1,38 +0,0 @@
import { ref } from 'vue';
import useScroll from "@/render/composables/useScroll";
const messagesRef = ref();
/* seed the canvas with messages */
const seedCanvas = (messages: Message[]) => {
messagesRef.value = messages;
}
const addMessage = (message: Message) => {
messagesRef.value.push(message);
}
const updateMessage = (message: Message) => {
let target_message = messagesRef.value.filter((m: Message) => {
return m.uid = message.uid;
})[0];
if (target_message) {
target_message = message;
}
}
export default function useMessages() {
const { updateScrollRef, adjustScroll } = useScroll("messenger");
return {
messagesRef,
seedCanvas,
addMessage,
updateMessage
};
}

View file

@ -0,0 +1,83 @@
const debounce = (fn, delay) => {
let timeoutId;
const wrapper = (...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
}, delay);
}
return wrapper;
}
const SmartForm =
{
props: ["fields", "channel", "modifier"],
setup(props)
{
let logsEl;
const submit = debounce(async (data) => {
let object = {};
data.forEach(function(value, key){
object[key] = value;
});
const err = await window.mainApi.invoke(props.channel, object)
if (err) {
logsEl.innerText = err;
logsEl.style.color = "red";
return;
}
logsEl.style.color = "green";
logsEl.innerText = props.modifier;
}, 2000);
Vue.onMounted(() => {
const el = document.getElementById("smart-form");
logsEl = el.querySelector(".smart-form-logs");
logsEl.innerText = props.modifier;
el.addEventListener("input", () => {
submit(new FormData(el))
});
});
},
template: `
<form id="smart-form" class="smart-form">
<div v-for="field in fields" style="display:flex">
<input type="text" class="text-field"
:name="field.name"
:value="field.value"
:placeholder="field.placeholder"
/>
</div>
<div class="smart-form-logs"></div>
</form>`
}
export default SmartForm;

View file

@ -0,0 +1,23 @@
const Header =
{
setup() {
const onNavBar = (command) => window.mainApi.send("nav", command);
return { onNavBar };
},
template: `
<button id="header-titlebar" />
<span id="header-menu">
<button
class="header-menu-button header-exit-button button"
@click.prevent="onNavBar('close')"
/>
<button
class="header-menu-button header-min-button button"
@click.prevent="onNavBar('min')"
/>
</span> `
}
export default Header;

View file

@ -1,89 +0,0 @@
<template>
<button id="titlebar" />
<span id="menu">
<button
class="menuButton exitButton"
@click.prevent="postNavBarExit"
/>
<button
class="menuButton minimizeButton"
@click.prevent="postNavBarMin"
/>
</span>
</template>
<script lang="ts">
// import { postNavBarExit, postNavBarMin } from "@/render/ipc";
import { defineComponent } from "vue";
export default defineComponent({
name: "Header",
setup() {
const postNavBarExit = () => {};
const postNavBarMin = () => {};
return {
postNavBarExit,
postNavBarMin
}
}
});
</script>
<style lang="scss">
#titlebar {
position: fixed;
width: 100vw;
height: 54px;
opacity: 0.75;
background-color: #EBEBEB;
-webkit-app-region: drag;
border: none;
outline: none;
z-index: 1;
}
#menu {
position: fixed;
margin-left: 20px;
margin-top: 20px;
z-index: 2;
}
.menuButton {
border: none;
outline:none;
min-width: 14px;
min-height: 14px;
border-radius: 7px;
}
.exitButton {
background-color: #FF6157;
}
.exitButton:active {
background: #c14645;
}
.minimizeButton {
background-color: #FFC12F;
margin-left: 8px;
}
.minimizeButton:active {
background-color: #c08e38;
}
</style>

View file

@ -0,0 +1,55 @@
import useText from "./control/text.js";
import draggify from "./control/draggify.js";
const Input =
{
props: ["person"],
setup(props)
{
const initials = Vue.ref("");
const recording = Vue.ref(false);
const { elementX, elementY } = draggify("input", "app", 15);
const { leftSide, show } = useText("input-text", "app", elementX);
window.mainApi.on("record", (status) => recording.value = status);
Vue.watch(() => props.person, (c, _p) => initials.value = getInitials(c));
return { initials, elementX, elementY, recording, leftSide, show };
},
template: `
<div id="input" :class="{ audio: recording }"
:style="{ top: elementY + 'px', left: elementX + 'px' }"
>
<div id="input-icon">{{ initials }}</div>
<input
id="input-text" type="text"
:class="[
{ leftSide: leftSide },
{ show: show },
{ recording: recording }
]"
/>
</div> `
}
function getInitials(name)
{
let rgx = new RegExp(/(\p{L}{1})\p{L}+/, 'gu');
let initials = [...name.matchAll(rgx)] || [];
return (
(initials.shift()?.[1] || '') + (initials.pop()?.[1] || '')
).toUpperCase();
}
export default Input;

View file

@ -1,220 +0,0 @@
<template>
<div
id="inputItem"
class="input-item"
:class="{ playing: recording }"
:style="{ top: `${elementY}px`, left: `${elementX}px` }"
>
<div>{{ initials }}</div>
<!-- Recording animation on space bar -->
<span v-if="recording" class="play"></span>
<span v-if="recording" class="pause"></span>
<!-- Show text input on key-down -->
<input
id="textInput"
type="text"
/>
<!-- Show suggestions menu on click -->
<!-- <Menu /> -->
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import draggify from "@/render/composables/useDraggify";
import useTextInputController from
"@/render/components/controllers/inputItem.control.text";
import useAudioInputController from
"@/render/components/controllers/inputItem.control.audio";
export default defineComponent({
name: "InputItem",
props: ["initials"],
setup() {
// Default values for position.
const xStart = 15;
const yStart = window.innerHeight - 200;
// Calculate position of inputItem on drag.
const { elementX, elementY } = draggify("inputItem", xStart, yStart, 15);
// Controllers for text and audio.
const { typing } = useTextInputController(elementX)
const { recording } = useAudioInputController(typing)
return {
elementX,
elementY,
recording
};
},
});
</script>
<style lang="scss" scoped>
.input-item {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
width: 40px;
height: 40px;
z-index: 3;
// To prevent window drag when overlapping with titlebar.
-webkit-app-region: no-drag;
// border: 4px solid #C6C6C6;
border-radius: 50%;
// Set opacity here to not affect child.
background-color: rgba(235, 235, 235, 0.75);
cursor: pointer;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
.play,
.pause {
z-index: 5;
&::before,
&::after {
-webkit-border-radius: 1000px;
-moz-border-radius: 1000px;
border-radius: 1000px;
content: "";
position: absolute;
height: 2.35em;
width: 2.35em;
left: 50%;
transform: translate(-50%, -50%);
top: 50%;
z-index: 0;
}
}
.play::before {
box-shadow: 0 0 0 rgba(195, 195, 195, 0);
}
.pause {
opacity: 0;
}
&.playing {
.play {
opacity: 0;
}
.pause {
opacity: 1;
&::before {
-moz-animation: circle1 1.5s infinite ease-in-out;
-o-animation: circle1 1.5s infinite ease-in-out;
-webkit-animation: circle1 1.5s infinite ease-in-out;
animation: circle1 1.5s infinite ease-in-out;
}
&::after {
-moz-animation: circle2 2.2s infinite ease-in-out;
-o-animation: circle2 2.2s infinite ease-in-out;
-webkit-animation: circle2 2.2s infinite ease-in-out;
animation: circle2 2.2s infinite ease-in-out;
}
}
}
}
.rec-icon {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: red;
transform: translate(14px);
animation-name: rec;
animation-duration: 0.5s;
}
@keyframes rec {
from {
transform: translate(14px) scale(0.3);
}
to {
transform: translate(14px) scale(1.0);
}
}
@keyframes circle1 {
0%,
100% {
box-shadow: 0 0 0 0.4em rgba(195, 195, 195, 0.4);
}
25% {
box-shadow: 0 0 0 0.15em rgba(195, 195, 195, 0.15);
}
50% {
box-shadow: 0 0 0 0.55em rgba(195, 195, 195, 0.55);
}
75% {
box-shadow: 0 0 0 0.25em rgba(195, 195, 195, 0.25);
}
}
@keyframes circle2 {
0%,
100% {
box-shadow: 0 0 0 0.25em rgba(195, 195, 195, 0.15);
}
25% {
box-shadow: 0 0 0 0.4em rgba(195, 195, 195, 0.3);
}
50% {
box-shadow: 0 0 0 0.15em rgba(195, 195, 195, 0.05);
}
75% {
box-shadow: 0 0 0 0.55em rgba(195, 195, 195, 0.45);
}
}
#textInput {
position: absolute;
opacity: 0;
min-width: 150px;
height: 16px;
border-radius: 18px;
padding: 10px;
margin-right: 10px;
margin-left: 10px;
outline: none;
border: none;
pointer-events: none;
background-color: white;
z-index: -1;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
transform: translateX(50px) scale(0.3);
}
</style>

View file

@ -0,0 +1,40 @@
import SmartForm from "./form.js";
const Login =
{
components: {
SmartForm
},
setup()
{
return;
},
template: `
<div id="login">
<SmartForm
:fields="[{
name: 'email',
value: null,
placeholder: 'Email',
},
{
name: 'password',
value: null,
placeholder: 'Password'
},
{
name: 'name',
value: null,
placeholder: 'Name'
}]"
:channel="'auth'"
:modifier="'Login or enter name to register.'"
/>
</div>`
}
export default Login;

View file

@ -1,180 +0,0 @@
<template>
<form id="login" @submit.prevent="submitForm">
<!-- login title -->
<div id="loginTitle">
<div>Login</div>
</div>
<!-- username and password forms -->
<div id="loginBox">
<!-- username -->
<div class="inputBox">
<input class="input" type="text" v-model="usr" />
<div class="inputModifier">Email</div>
</div>
<!-- password -->
<div class="inputBox">
<input class="input" type="password" v-model="pwd" />
<div class="inputModifier">Password</div>
</div>
</div>
<!-- submit button; position: fixed -->
<button class="submitButton button" type="submit">Submit</button>
</form>
<!-- back to login button: position: fixed -->
<!-- <button class="createAccountButton button" @click.prevent="switchView">Create account</button> -->
</template>
<script lang="ts">
import { defineComponent, ref } from "vue";
import { setProfile } from '@/render/composables/useProfile';
import { invokeLogin } from "@/render/ipc";
export default defineComponent({
name: "Login",
setup() {
const usr = ref("");
const pwd = ref("");
// Submit login credentials to the backend.
const submitForm = async () => {
try {
const profile = await invokeLogin({
email: usr.value,
password: pwd.value
}) as Profile;
setProfile(profile)
} catch(e) {
console.log(e);
}
}
return {
usr,
pwd,
submitForm
}
},
});
</script>
<style lang="scss" scoped>
#login {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#loginTitle {
font-family: "SF Pro Text";
min-width: 181px;
font-size: 24px;
font-weight: bold;
text-align: left;
padding-bottom: 10px;
}
#loginBox {
font-family: "SF Compact Display";
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-bottom: 20px;
}
.inputBox {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
.input {
background-color: #B9B9B9;
border: none;
outline: none;
padding: 16px;
text-decoration: none;
margin: 4px 2px;
cursor: pointer;
border-radius: 10px;
}
.inputModifier {
min-width: 181px;
font-size: 12px;
font-weight: bold;
text-align: left;
margin-left: 15px;
}
.submitButton {
font-family: "SF Compact Display";
position: fixed;
margin-top: 141px;
background-color: #58C4FD;
color: white;
padding: 10px 30px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
.submitButton:active {
background-color: #4296C3;
}
.createAccountButton {
position: absolute;
border: none;
background-color: #EBEBEB;
text-decoration: none;
color: #58C4FD;
font-weight: bold;
bottom: 20px;
right: 20px;
}
.button {
border: none;
outline: none;
text-decoration: none;
}
.button:hover {
cursor: pointer;
}
.invalid {
margin-bottom: 30px;
font-family: "SF Compact Display";
font-weight: bold;
font-size: 14px;
color: #F53737;
}
</style>

View file

@ -0,0 +1,62 @@
import Bubble from "./bubble.js";
import Context from "./context.js";
const Message =
{
components: {
Bubble,
Context
},
props: ["modifier", "content", "context", "avatar", "id", "seen"],
setup(props)
{
return { calcChild };
},
template: `
<div :id="id" :class="'message ' + modifier + '-message'">
<Bubble
v-for="(c, index) in content"
:id="'bubble-' + id + '-' + index"
:category="c.category"
:text="c.text"
:html="c.html"
:blob="c.blob"
:modifier="modifier"
:child="calcChild(index, content.length)"
/>
<Context
:modifier="modifier"
:context="context"
:avatar="avatar"
:id="id"
/>
</div> `
}
function calcChild(index, len)
{
if (len === 1)
{
return "none-child";
}
else if (index === 0)
{
return "first-child";
}
else if (index === len - 1)
{
return "last-child";
}
else
{
return "middle-child";
}
}
export default Message;

View file

@ -0,0 +1,86 @@
import onDrop from "./control/drop.js";
import scroll from "./control/scroll.js";
import WS from "./ws.js";
import Input from "./input.js";
import Settings from "./settings.js";
import Message from "./message.js";
const Messenger = {
components: {
WS,
Settings,
Message,
Input
},
setup()
{
const person = Vue.ref("");
const messages = Vue.ref([]);
let current = Vue.ref();
Vue.onMounted(() => {
window.mainApi.on("message", (update) => {
if (update.person)
{
person.value = update.person;
messages.value = update.messages;
current.value = messages.value.at(-1);
}
else if (update.name)
{
person.value = update.name;
}
else if (update.modifier)
{
messages.value.push(update);
current.value = update;
}
else if (update.category)
{
current.value.content.push(update);
}
else if (update.context)
{
current.value.context = update.context;
}
else
{
current.value.content.at(-1).text = update.text;
}
setTimeout(scroll, 10);
});
window.mainApi.send("messenger");
});
return { person, messages, onDrop };
},
template: `
<Settings />
<WS />
<Input :person="person" />
<div id="messenger"
@drop="onDrop($event)"
@dragover.prevent
@dragenter.prevent
>
<Message
v-for="message in messages"
v-bind="message"
/>
</div>`
}
export default Messenger;

View file

@ -1,82 +0,0 @@
<template>
<!-- Position fixed items -->
<InputItem :initials="profile.initials"/>
<Settings />
<div id="recIcon" />
<!-- List of message bubbles. -->
<div id="messenger">
<Bubble
v-for="message in messages"
:text="message.content.text"
:context="message.context"
:key="message[0]"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted } from "vue";
import InputItem from "@/render/components/inputItem.vue";
import Settings from "@/render/components/settings.vue";
import Bubble from "@/render/components/bubble.vue";
import { profile } from "@/render/composables/useProfile";
import { messages } from "@/render/composables/useMessages";
export default defineComponent({
name: "Messenger",
components: {
InputItem,
Settings,
Bubble
},
setup() {
return {
messages,
profile
};
},
});
</script>
<style lang="scss" scoped>
#messenger {
width: 100vw;
height: 100vh;
overflow: auto;
}
#messenger::-webkit-scrollbar {
display: none;
}
#recIcon {
position: fixed;
right: 0;
opacity: 0;
transform: scale(0.0);
margin-top: 24px;
margin-right: 66px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #FF3B3B;
z-index: 2;
}
</style>

View file

@ -0,0 +1,40 @@
const Settings =
{
setup() {
const active = Vue.ref(false);
function hideSettings(e) {
if (e.key == "Escape") {
active.value = false;
window.removeEventListener("keydown", hideSettings);
}
}
function showSettings() {
active.value = true;
window.addEventListener("keydown", hideSettings);
}
const logout = async () => {
await window.mainApi.send("logout");
}
return { showSettings, active, logout, window };
},
template: `
<div v-if="active" id="settings" >
<button class="settings-logout-button button" @click="logout">
Logout
</button>
</div>
<button v-else class="settings-icon" @click="showSettings">
<img :src="window.path + 'assets/settingsIcon.svg'">
</button>`
}
export default Settings;

View file

@ -1,164 +0,0 @@
<template>
<!-- Settings icon. -->
<button
class="settingsIcon"
v-if="toggleSettings == false"
@click="onActive"
>
<div class="settingsButtonDot"></div>
<div class="settingsButtonDot"></div>
<div class="settingsButtonDot"></div>
</button>
<!-- Settings div. -->
<div id="settings" v-show="toggleSettings">
</div>
<button
v-if="toggleSettings"
class="settingsOption"
@click="onLogout"
>
Logout
</button>
</template>
<script lang="ts">
import { defineComponent, ref } from "vue";
import { clearProfile } from "@/render/composables/useProfile"
import { invokeLogout } from "@/render/ipc";
export default defineComponent({
name: "Settings",
setup() {
const toggleSettings = ref(false);
// Listen for escape key to close settings.
const onEscape = (e: any) => {
if(e.key === "Escape") {
toggleSettings.value = false;
}
window.removeEventListener("keydown", onEscape);
}
// When settings is showing.
const onActive = () => {
toggleSettings.value = true;
window.addEventListener("keydown", onEscape);
}
// We ask server to log us out.
const onLogout = async () => {
console.log("Submitting logout request.");
try {
await invokeLogout();
clearProfile();
} catch(e) {
console.log('error')
}
}
return {
onActive,
toggleSettings,
onLogout
}
}
})
</script>
<style lang="scss" scoped>
#settings {
position: fixed;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
background-color: #EBEBEB;
opacity: 0.75;
z-index: 4;
animation-name: appear;
animation-duration: 0.5s;
}
@keyframes appear {
from {
opacity: 0
}
to {
opacity: 0.75
}
}
.settingsIcon {
position: fixed;
right: 0;
border: none;
outline: none;
display: flex;
flex-direction: row;
padding: 5px;
margin-right: 20px;
margin-top: 19px;
z-index: 2;
background-color: Transparent;
}
.settingsIcon:hover {
cursor: pointer;
}
.settingsButtonDot {
width: 5px;
height: 5px;
margin: 2px;
border-radius: 2.5px;
background-color: #9B9B9B;
}
.settingsOption {
font-family: "SF Compact Display";
background-color: #B7B7B7;
padding: 10px 30px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
border: none;
outline: none;
text-decoration: none;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 5;
}
.settingsOption:hover {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,50 @@
const Splash =
{
template: `
<div id="splash">
<svg
xmlns="http://www.w3.org/2000/svg"
width="57.046"
height="52.759"
viewBox="0 0 57.046 52.759"
>
<g transform="translate(-4127 -507)">
<g transform="translate(3949 332.206)">
<g transform="translate(178 174.794)">
<g transform="translate(0 27.405)">
<circle
cx="12.677"
cy="12.677"
r="12.677"
transform="translate(0 0)"
fill="#383838"
/>
<circle
cx="12.677"
cy="12.677"
r="12.677"
transform="translate(31.692 0)"
fill="#383838"
/>
</g>
<circle
cx="12.677"
cy="12.677"
r="12.677"
transform="translate(15.822 0)"
fill="#383838"
/>
<path
d="M36.27,83.67"
transform="translate(-23.569 -43.588)"
fill="#ff0"
/>
</g>
</g>
</g>
</svg>
</div>
`
}
export default Splash;

View file

@ -1,69 +0,0 @@
<template>
<div id="splash">
<svg
xmlns="http://www.w3.org/2000/svg"
width="57.046"
height="52.759"
viewBox="0 0 57.046 52.759"
>
<g transform="translate(-4127 -507)">
<g transform="translate(3949 332.206)">
<g transform="translate(178 174.794)">
<g transform="translate(0 27.405)">
<circle
cx="12.677"
cy="12.677"
r="12.677"
transform="translate(0 0)"
fill="#383838"
/>
<circle
cx="12.677"
cy="12.677"
r="12.677"
transform="translate(31.692 0)"
fill="#383838"
/>
</g>
<circle
cx="12.677"
cy="12.677"
r="12.677"
transform="translate(15.822 0)"
fill="#383838"
/>
<path
d="M36.27,83.67"
transform="translate(-23.569 -43.588)"
fill="#ff0"
/>
</g>
</g>
</g>
</svg>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "Splash",
});
</script>
<style lang="scss" scoped>
#splash {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View file

@ -0,0 +1,35 @@
const WS =
{
setup()
{
const status = Vue.ref();
Vue.onMounted(() => {
window.mainApi.on("ws", (val) => {
status.value = val;
});
});
return { status, window }
},
template: `
<div id="ws">
<img id="ws-logo" :src="window.path + 'assets/connectLogo.svg'"
:class="{ move: status }"
>
<div id="ws-dots"
:class="{ move: status }"
>
<div class="ws-dot"></div>
</div>
</div> `
}
export default WS;

View file

@ -1,199 +0,0 @@
import { onMounted, ref, onUnmounted } from "vue";
//---Dragabble Helper Funcs--------------------------------------
// Calculate distance to nearest side.
function calcSideProximity (elementX: number, winW: number) {
// Calc right short.
let short = winW - elementX - 40;
// See if it's left short.
if (elementX + 20 < winW / 2) {
short = elementX
}
return short
}
// Update elementX or elementY value on window resize
function calcPosition (elementPosition: number, percent: number,
short: number, win: number) {
// Is it close to the right/bottom side?
if (percent > 0.75) {
elementPosition = win - short - 40;
}
// Is it not close to a side?
if (percent < 0.75 && percent > 0.25) {
elementPosition = win * percent
}
return elementPosition
}
export default function draggify(elementId: string, xStart: number,
yStart: number, margin: number) {
let element: HTMLElement | null;
// Cords of inputItem.
const elementX = ref(0);
const elementY = ref(0);
// Position of inputItem on terms of percentage of window.
let percentX: number;
let percentY: number;
// How close inputItem is to closest X or Y side.
let xShort: number;
let yShort: number;
// Keep track of window size;
let winW = window.innerWidth;
let winH = window.innerHeight;
//---Reposition Anime-----------------------------------------------
// Move element to target smoothly.
const repositionAnime = (xChange: number, yChange: number) => {
const xStep = xChange / 6000;
const yStep = yChange / 6000;
for (let i = 1; i <= 6000; i++) {
setTimeout(() => {
elementX.value += xStep;
elementY.value += yStep;
}, 16) // 60fps
}
}
//---Event Handlers-----------------------------------------------
// Update the position of inputItem on mouse dragging.
const onMouseMove = (e: any) => {
e.preventDefault()
element = document.getElementById(elementId);
if (element) {
const inputItemRect = element.getBoundingClientRect();
elementX.value = inputItemRect.left + e.movementX;
elementY.value = inputItemRect.top + e.movementY;
}
}
// Add an event listener for dragging.
const onMouseDown = (_e: any) => {
_e.preventDefault()
window.addEventListener('mousemove', onMouseMove, true);
}
// Update position references when user is done moving targetEl.
const onMouseUp = (_e: any) => {
window.removeEventListener('mousemove', onMouseMove, true);
// See if and calculate reposition.
let x = 0; // vector change
let y = 0;
if (elementX.value < 0) {
x = (elementX.value - margin)*-1
}
if (elementX.value > winW - 40) {
const b = winW - 40 - margin;
x = (elementX.value - b)*-1
}
// Reposition y
if (elementY.value < 0) {
y = (elementY.value - margin)*-1
}
if (elementY.value > winH - 40) {
const d = winH - 40 - margin;
y = (elementY.value - d)*-1
}
// Reposition if needed.
if (x !== 0 || y !== 0) repositionAnime(x, y);
xShort = calcSideProximity(elementX.value, winW);
yShort = calcSideProximity(elementY.value, winH);
// Update percentages.
percentX = elementX.value / winW;
percentY = elementY.value / winH;
// Save position.
const position = {
x: elementX.value,
y: elementY.value
}
window.localStorage.setItem("inputItem_position", JSON.stringify(position));
}
// Update position of targetEl on windowResize.
const onWindowResize = (_e: any) => {
// Update window dimensions.
winW = window.innerWidth;
winH = window.innerHeight;
elementX.value = calcPosition(elementX.value, percentX, xShort, winW);
elementY.value = calcPosition(elementY.value, percentY, yShort, winH);
}
//---------------------------------------------------------------
onMounted(() => {
element = document.getElementById(elementId);
if (element) {
element.addEventListener('mousedown', onMouseDown, false);
}
window.addEventListener("mouseup", onMouseUp, false);
// Initialize the positional references.
percentX = elementX.value / window.innerWidth;
percentY = elementY.value / window.innerHeight;
xShort = calcSideProximity(elementX.value, window.innerWidth);
yShort = calcSideProximity(elementX.value, window.innerHeight);
// Then, we can listen for window resize.
window.addEventListener('resize', onWindowResize, false);
});
// Try loading initPosition, otherwise set default values
let initPosition: any;
const rawData = window.localStorage.getItem("inputItem_position")
if (rawData) {
initPosition = JSON.parse(rawData)
} else {
initPosition = {x: xStart, y: yStart}
}
elementX.value = initPosition.x;
elementY.value = initPosition.y;
// remove event listeners on component dismount.
onUnmounted(() => {
window.removeEventListener('resize', onWindowResize);
window.removeEventListener('mouseup', onMouseUp);
if (element) element.removeEventListener('mousedown', onMouseDown);
})
return {
elementX,
elementY
}
}

View file

@ -1,54 +0,0 @@
import { IpcRendererEvent } from "electron";
export class IpcRendererListener<InputParam> implements IIpcListener<InputParam> {
readonly channel: string;
readonly _listenerCallback: IpcListenerCallback<InputParam>;
constructor(options: {
channel: string;
listenerCallback: IpcListenerCallback<InputParam>;
}) {
this.channel = options.channel;
this._listenerCallback = options.listenerCallback;
}
listen() {
this.remove();
window.ipcRenderer.on(this.channel, this._onPost);
}
remove() {
window.ipcRenderer.removeAllListeners(this.channel);
}
private _onPost = (_e: IpcRendererEvent, payload: InputParam): void => {
console.log(`[IPC] Post: ${this.channel}`);
this._listenerCallback(payload);
}
}
export default function useIpcRenderer () {
const invoke = async (endpoint: string, payload: any) => {
try {
const res = await window.ipcRenderer.invoke(endpoint, payload);
return res;
} catch (e) {
throw e;
}
}
const post = (endpoint: string, payload: any) => {
window.ipcRenderer.send(endpoint, payload);
};
return {
invoke,
post,
}
}

View file

@ -1,33 +0,0 @@
// shared
import { ref, Ref } from "vue";
import useScroll from "@/render/composables/useScroll";
export const messages: Ref<Array<Message>> = ref([]);
export const setMessages: IpcListenerCallback<Array<Message>> = (payload) => {
messages.value = payload as Array<Message>;
}
export const addMessage: IpcListenerCallback<Message> = (payload) => {
messages.value.push(payload as Message);
}
export const updateMessage: IpcListenerCallback<Message> = (payload) => {
const message = payload as Message;
let targetMessage = messages.value.filter((m: Message) => {
return m.uid = message.uid;
})[0];
if (targetMessage) {
targetMessage = message;
}
}
export default {
messages,
setMessages,
addMessage,
updateMessage
};

View file

@ -1,27 +0,0 @@
// shared
import { ref } from "vue";
export const profile = ref();
export const authComplete = ref(false);
export const setProfile: IpcListenerCallback<Profile | null> = (payload) => {
payload ? profile.value = payload : clearProfile();
showRender();
};
export const clearProfile = () => {
profile.value = null;
};
export const showRender = () => {
authComplete.value = true;
};
export default {
setProfile,
clearProfile,
profile,
showRender,
authComplete,
};

View file

@ -1,29 +0,0 @@
export default function useScroll(element: string) {
let isScrolledToBottom: boolean;
const view = document.getElementById(element)
// Update isScrolledToBottom
const updateScrollRef = () => {
if (view) isScrolledToBottom = view.scrollHeight - view.clientHeight <= view.scrollTop + 1;
return isScrolledToBottom;
}
// Adjust scroll after we add content to the messenger.
const adjustScroll = () => {
if (view) {
view.scrollTo({
top: view.scrollHeight - view.clientHeight,
behavior: 'smooth'
});
}
}
return {
updateScrollRef,
adjustScroll,
};
}

17
src/render/index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="./main.css">
</head>
<body>
<div id="app"></div>
<!-- vue@3.2.11 -->
<script src="./vendor/vue.js"></script>
<script type="module" src="./index.js"></script>
</body>
</html>

5
src/render/index.js Normal file
View file

@ -0,0 +1,5 @@
import App from "./components/app.js";
window.path = "./";
Vue.createApp(App).mount("#app");

View file

@ -1,80 +0,0 @@
import useIpc from "@/render/composables/useIpcRend";
import * as rendererListeners from "./listeners";
const { post, invoke } = useIpc();
/**
*
* Account and auth related endpoints
*
*/
export const invokeLogin = async (
payload: LoginPayload
): Promise<Profile | Error> => (
await invoke('invoke-account-login', JSON.stringify(payload))
);
export const invokeLogout = async (): Promise<void> => (
await invoke("invoke-account-logout", null)
);
/**
*
* Audio endpoints
*
*/
export const postAudioChunk = (chunk: ArrayBuffer): void => (
post("post-audio-collect", chunk)
);
export const invokeReturnAudio = async (): Promise<ArrayBuffer[] | Error> => (
await invoke("invoke-audio-flush", null)
);
/**
*
* Crimata Platform (session) endpoints
*
*/
export const invokeSession = async (cid: string): Promise<Profile | Error> => (
await invoke("messenger-init", cid)
);
export const postMessage = (payload: Message): void => (
post('post-session-send', payload)
);
export const postAppMount = (): void => (
post('post-app-mount', null)
);
/**
*
* Ipc Renderer Listeners
*
*/
let ipcListeners: IPCListeners = {};
export const initIpcRendererListeners = () => {
for (const [key, listener] of Object.entries(rendererListeners)) {
if (!(key in ipcListeners)) {
ipcListeners[key] = listener;
listener.listen();
}
}
};
export const removeListeners = () => {
for (const [key, listener] of Object.entries(rendererListeners)) {
listener.remove();
}
ipcListeners = {};
};

View file

@ -1,32 +0,0 @@
import { IpcRendererListener } from "./composables/useIpcRend"
import { setProfile } from "./composables/useProfile";
import { setMessages, addMessage, updateMessage } from "./composables/useMessages";
const SET_PROFILE_CHANNEL = "set-profile";
const INIT_MESSAGES_CHANNEL = "init-messages";
const ADD_MESSAGE_CHANNEL = "add-message";
const UPDATE_MESSAGE_CHANNEL = "update-message";
export const setProfileListener = new IpcRendererListener({
channel: SET_PROFILE_CHANNEL,
listenerCallback: setProfile
});
export const initMessagesListener = new IpcRendererListener({
channel: INIT_MESSAGES_CHANNEL,
listenerCallback: setMessages
});
export const addMessagesListener = new IpcRendererListener({
channel: ADD_MESSAGE_CHANNEL,
listenerCallback: addMessage
});
export const updateMessagesListener = new IpcRendererListener({
channel: UPDATE_MESSAGE_CHANNEL,
listenerCallback: updateMessage
});

582
src/render/main.css Normal file
View file

@ -0,0 +1,582 @@
@font-face {
font-family: "Default";
src: url("assets/fonts/SF-Pro-Text-Regular.otf");
}
@font-face {
font-family: "Compact";
src: url("assets/fonts/SF-Compact-Display-Bold.otf");
}
@font-face {
font-family: "Rounded";
src: url("assets/fonts/SF-Compact-Rounded-Bold.otf");
}
html, body {
margin: 0;
padding: 0;
/*background-color: rgba(235, 235, 235, 0.75);*/
}
/* The first word of the class is the component that it modifys. */
#splash {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
#app {
position: relative; /* must explicitly be declared */
font-family: "Default";
-webkit-font-smoothing: antialiased;
height: 100vh; /* 100% for website */
width: 100vw; /* 100% for website */
border-radius: 15px;
}
#header-titlebar {
position: absolute;
width: 100%;
height: 54px;
opacity: 0.75;
background-color: #EBEBEB;
-webkit-app-region: drag;
border: none;
outline: none;
z-index: 1;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
}
.contacts {
margin: 0;
padding: 0;
border-radius: inherit;
background-color: #DBDBDB;
}
.contacts > li {
padding: 12px;
display: flex;
}
.contacts img {
height: 30px;
border-radius: 50%;
}
.contacts .info {
margin-left: 10px;
margin-right: 10px;
}
.contacts .name {
}
.contacts .email {
font-family: "Compact";
font-size: 12px;
color: #898989;
overflow: w;
word-wrap: break-word;
}
#header-menu {
position: absolute;
margin-left: 20px;
margin-top: 20px;
z-index: 2;
}
.header-menu-button {
min-width: 14px;
min-height: 14px;
border-radius: 50%;
}
.header-exit-button {
background-color: #FF6157;
}
.header-exit-button:active {
background: #c14645;
}
.header-min-button {
background-color: #FFC12F;
margin-left: 8px;
}
.header-min-button:active {
background-color: #c08e38;
}
#login {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#messenger {
width: 100%;
height: 100%;
overflow: auto;
}
/* hide native scrollbar */
#messenger::-webkit-scrollbar {
display: none;
}
#ws {
position: absolute;
width: 100%;
height: 54px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
#ws-logo {
transition: all 0.5s;
}
#ws-logo.move {
transform: translateX(15px);
}
#ws-dots {
opacity: 0;
transition: all 0.5s;
}
#ws-dots.move {
opacity: 1;
transform: translateX(-15px);
}
.ws-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #48E065;
position: relative;
transform: translateX(-15px);
animation: ws-dot-flashing 1s infinite linear alternate;
animation-delay: .25s;
}
.ws-dot::before, .ws-dot::after {
content: '';
display: inline-block;
position: absolute;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
}
.ws-dot::before {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #48E065;
left: -9px;
animation: ws-dot-flashing 1s infinite alternate;
animation-delay: 0s;
}
.ws-dot::after {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #48E065;
left: 9px;
animation: ws-dot-flashing 1s infinite alternate;
animation-delay: 0.5s;
}
@keyframes ws-dot-flashing {
0% {
background-color: #48E065;
}
50%,
100% {
background-color: #9B9B9B;
}
}
#input {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
width: 40px;
height: 40px;
z-index: 3;
/* To prevent window drag when overlapping with titlebar. */
-webkit-app-region: no-drag;
border-radius: 50%;
/* Set opacity here to not affect child. */
background-color: rgba(235, 235, 235, 0.75);
cursor: pointer;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
}
#input-text {
position: absolute;
min-width: 150px;
height: 16px;
border-radius: 18px;
padding: 10px;
outline: none;
border: none;
font-size: 14px;
pointer-events: none;
background-color: white;
z-index: -1;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
transform-origin: center;
opacity: 0;
transform: translateX(70%);
transition: all 0.5s;
}
#input-text.show {
opacity: 1;
}
#input-text.leftSide {
transform: translateX(-70%);
}
#settings {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(235, 235, 235, 0.75);
z-index: 4;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
animation-name: settings-appear;
animation-duration: 0.5s;
border-radius: inherit;
}
@keyframes settings-appear {
from {
background-color: rgba(235, 235, 235, 0);
}
to {
background-color: rgba(235, 235, 235, 0.75);
}
}
.settings-icon {
position: absolute;
right: 0;
border: none;
outline: none;
display: flex;
flex-direction: row;
padding: 5px;
margin-right: 20px;
margin-top: 19px;
z-index: 2;
background-color: Transparent;
}
.settings-icon:hover {
cursor: pointer;
}
.settings-account {
font-size: 12px;
font-weight: bold;
margin-bottom: 25px;
}
.settings-logout-button {
font-family: "Compact";
background-color: #B7B7B7;
padding: 10px 20px;
border-radius: 20px;
font-size: 14px;
}
.settings-logout-button:hover {
cursor: pointer;
}
.settings-version {
position: absolute;
left: 50%;
top: 75%;
transform: translate(-50%, -50%);
font-size: 12px;
font-weight: bold;
color: #575757;
}
.message {
width: inherit;
display: flex;
flex-direction: column;
padding-top: 9px;
padding-bottom: 9px;
animation-name: message-init-anim;
animation-duration: 0.25s;
}
@keyframes message-init-anim {
from {
opacity: 0;
} to {
opacity: 1;
}
}
.message:first-child {
margin-top: 55px;
}
.message:last-child {
margin-bottom: 6px;
}
.message-session {
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
font-family: "Compact";
font-size: 12px;
color: #9B9B9B;
margin-bottom: 18px;
}
.client-message {
align-items: flex-end;
}
.ai-message {
align-items: flex-start;
}
.admin-message {
align-items: center;
}
.bubble {
position: relative;
max-width: 66%;
font-size: 14px;
border-radius: 18px;
margin-bottom: 4px;
overflow-wrap: break-word;
}
.bubble-notify {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #58D9FF;
top: -5px;
left: -5px;
border: 2px solid #EBEBEB;
transform: scale(0);
animation-name: bubble-notify-anim;
animation-duration: 5s;
}
@keyframes bubble-notify-anim {
0%, 90% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
.ai-bubble {
background-color: #FFFFFF;
margin-left: 15px;
}
.client-bubble {
color: white;
background-color: #58C4FD;
margin-right: 15px;
}
.ai-first-child {
border-bottom-left-radius: 9px;
}
.ai-middle-child {
border-top-left-radius: 9px;
border-bottom-left-radius: 9px;
}
.ai-last-child {
border-top-left-radius: 9px;
}
.client-first-child {
border-bottom-right-radius: 9px;
}
.client-middle-child {
border-top-right-radius: 9px;
border-bottom-right-radius: 9px;
}
.client-last-child {
border-top-right-radius: 9px;
}
.bubble > p {
margin: 0px;
padding: 10px;
}
.bubble > div {
border-radius: inherit;
}
.bubble > img {
border-radius: inherit;
display: block;
max-width: 100%;
}
.bubble a {
display: inline-block;
font-family: "Rounded";
text-decoration: none;
border-radius: inherit;
background-color: #D9D9D9;
padding: 10px;
color: #727272;
}
.context {
position: relative;
min-height: 14px;
display: flex;
align-items: center;
font-family: "Compact";
font-size: 12px;
margin-top: 5px;
}
.ai-context {
margin-left: 15px;
}
.client-context {
margin-right: 15px;
}
.context-avatar {
margin-right: 5px;
border-radius: 50%;
height: 30px;
}
/* ---------- shared between components ---------- */
.button {
border: none;
outline: none;
text-decoration: none;
}
/*.button:hover {
cursor: pointer;
}*/
/* shake a div to signal error*/
.shake {
animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
}
@keyframes shake {
10%, 90% {
transform: translate3d(-1px, 0, 0);
}
20%, 80% {
transform: translate3d(2px, 0, 0);
}
30%, 50%, 70% {
transform: translate3d(-4px, 0, 0);
}
40%, 60% {
transform: translate3d(4px, 0, 0);
}
}
.audio {
animation-name: audio-anim;
animation-duration: 2s;
animation-iteration-count: infinite;
}
@keyframes audio-anim {
0%,
100% {
box-shadow: 0 0 0 0.4em rgba(195, 195, 195, 0.4), 0 0 0 0.25em rgba(195, 195, 195, 0.15);
}
25% {
box-shadow: 0 0 0 0.15em rgba(195, 195, 195, 0.15), 0 0 0 0.4em rgba(195, 195, 195, 0.3);
}
50% {
box-shadow: 0 0 0 0.55em rgba(195, 195, 195, 0.55), 0 0 0 0.15em rgba(195, 195, 195, 0.05);
}
75% {
box-shadow: 0 0 0 0.25em rgba(195, 195, 195, 0.25), 0 0 0 0.55em rgba(195, 195, 195, 0.45);
}
}
.smart-form {
width: 225px;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.text-field {
width: 100%;
padding: 16px;
background: none;
border: none;
margin: 4px;
font-size: 14px;
border-radius: 10px;
background-color: #d1d1d1;
outline: none;
}
.smart-form-logs {
font-family: "Compact";
font-size: 12px;
text-align: left;
margin-left: 10px;
}

View file

@ -1,21 +0,0 @@
// src/main.ts
import App from "./App.vue";
import mitt from "mitt";
import { createApp } from "vue";
import { initIpcRendererListeners } from "./ipc"
// Handle ipcMain events.
initIpcRendererListeners();
// Handle events.
const emitter = mitt();
const app = createApp(App);
app.provide("mitt", emitter);
app.mount("#app");

Some files were not shown because too many files have changed in this diff Show more