Compare commits

...
110 changed files with 17915 additions and 31301 deletions

38
.eslintrc.js Normal file
View file

@ -0,0 +1,38 @@
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
}
}
]
};

37
.gitignore vendored
View file

@ -1,5 +1,36 @@
.DS_Store
/node_modules
/out
node_modules
/dist
/crate/target/*
/.log
*.sublime-workspace
crash.log
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
#Electron-builder output
/dist_electron
#Electron-builder config
electron-builder.yml
#Window and session states
session.json
window.json

63
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,63 @@
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
View file

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

29
README.md Normal file
View file

@ -0,0 +1,29 @@
# crimata-electron
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn electron:serve
```
### Compiles and minifies for production
```
yarn electron:build
```
<!-- ### Run your unit tests
```
yarn test:unit
```
### Lints and fixes files
```
yarn lint
``` -->
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
babel.config.js Normal file
View file

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

79
build/config.gypi Normal file
View file

@ -0,0 +1,79 @@
# Do not edit. File was generated by node-gyp's "configure" step
{
"target_defaults": {
"cflags": [],
"default_configuration": "Release",
"defines": [],
"include_dirs": [],
"libraries": []
},
"variables": {
"asan": 0,
"build_v8_with_gn": "false",
"coverage": "false",
"dcheck_always_on": 0,
"debug_nghttp2": "false",
"debug_node": "false",
"enable_lto": "false",
"enable_pgo_generate": "false",
"enable_pgo_use": "false",
"error_on_warn": "false",
"force_dynamic_crt": 0,
"host_arch": "x64",
"icu_data_in": "../../deps/icu-tmp/icudt67l.dat",
"icu_endianness": "l",
"icu_gyp_path": "tools/icu/icu-generic.gyp",
"icu_path": "deps/icu-small",
"icu_small": "false",
"icu_ver_major": "67",
"is_debug": 0,
"llvm_version": "0.0",
"napi_build_version": "6",
"node_byteorder": "little",
"node_debug_lib": "false",
"node_enable_d8": "false",
"node_install_npm": "true",
"node_module_version": 83,
"node_no_browser_globals": "false",
"node_prefix": "/",
"node_release_urlbase": "https://nodejs.org/download/release/",
"node_shared": "false",
"node_shared_brotli": "false",
"node_shared_cares": "false",
"node_shared_http_parser": "false",
"node_shared_libuv": "false",
"node_shared_nghttp2": "false",
"node_shared_openssl": "false",
"node_shared_zlib": "false",
"node_tag": "",
"node_target_type": "executable",
"node_use_bundled_v8": "true",
"node_use_dtrace": "true",
"node_use_etw": "false",
"node_use_node_code_cache": "true",
"node_use_node_snapshot": "true",
"node_use_openssl": "true",
"node_use_v8_platform": "true",
"node_with_ltcg": "false",
"node_without_node_options": "false",
"openssl_fips": "",
"openssl_is_fips": "false",
"shlib_suffix": "83.dylib",
"target_arch": "x64",
"v8_enable_31bit_smis_on_64bit_arch": 0,
"v8_enable_gdbjit": 0,
"v8_enable_i18n_support": 1,
"v8_enable_inspector": 1,
"v8_enable_pointer_compression": 0,
"v8_no_strict_aliasing": 1,
"v8_optimized_debug": 1,
"v8_promise_internal_field_count": 1,
"v8_random_seed": 0,
"v8_trace_maps": 0,
"v8_use_siphash": 1,
"want_separate_host_toolset": 0,
"xcode_version": "11.0",
"nodedir": "/Users/Enrique/Library/Caches/node-gyp/14.4.0",
"standalone_static_library": 1
}
}

View file

@ -1,8 +0,0 @@
{
"folders":
[
{
"path": "."
}
]
}

View file

@ -1,14 +0,0 @@
<?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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

6
jest.config.js Normal file
View file

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

6
jsconfig.json Normal file
View file

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

13151
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,54 +1,99 @@
{
"name": "crimata",
"version": "1.0.0",
"description": "Cross-platform messenger app (Electron, Vue3)",
"main": "src/main.js",
"name": "Crimata",
"version": "0.9.8",
"private": true,
"description": "Cross-platform messenger application built with electron, vue3, and TS.",
"author": {
"name": "Enrique Hernandez"
},
"scripts": {
"start": "electron-forge start",
"test": "echo \"Error: no test specified\" && exit 1",
"package": "electron-forge package",
"make": "electron-forge make"
},
"author": "Andrew Gundersen",
"license": "ISC",
"devDependencies": {
"@electron-forge/cli": "^6.0.0-beta.63",
"@electron-forge/maker-deb": "^6.0.0-beta.63",
"@electron-forge/maker-rpm": "^6.0.0-beta.63",
"@electron-forge/maker-squirrel": "^6.0.0-beta.63",
"@electron-forge/maker-zip": "^6.0.0-beta.63",
"electron": "^18.2.3"
"build": "vue-cli-service electron:build",
"dev": "vue-cli-service electron:serve",
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps"
},
"main": "background.js",
"dependencies": {
"axios": "^0.21.1",
"base64-arraybuffer": "^1.0.1",
"electron-store": "^8.0.0",
"ws": "^7.3.1",
"electron-squirrel-startup": "^1.0.0"
"@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",
"core-js": "^3.6.5",
"electron-is-dev": "^2.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"
},
"config": {
"forge": {
"packagerConfig": {},
"makers": [
{
"name": "@electron-forge/maker-squirrel",
"config": {}
},
{
"name": "@electron-forge/maker-zip",
"platforms": [
"darwin"
]
},
{
"name": "@electron-forge/maker-deb",
"config": {}
},
{
"name": "@electron-forge/maker-rpm",
"config": {}
"devDependencies": {
"@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": {
"preload": "src/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"
}
}

21
public/index.html Normal file
View file

@ -0,0 +1,21 @@
<!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>
<!-- <h1>Hello World!</h1>
We are using Node.js <span id="node-version"></span>, Chromium
<span id="chrome-version"></span>, and Electron
<span id="electron-version"></span>. -->
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

183
src/App.vue Normal file
View file

@ -0,0 +1,183 @@
<template>
<!-- Only render when profile has been set -->
<div id="app" v-if="(ready)">
<button id="titlebar" />
<!-- Minimize and exit buttons. -->
<span class="menu">
<button
class="menuButton exitButton"
@click.prevent="post('nav-bar', 'close')"
/>
<button
class="menuButton minimizeButton"
@click.prevent="post('nav-bar', 'min')"
/>
</span>
<!-- Main Pages -->
<Messenger
v-if="profile"
:profile="profile"
:newMessages="newMessages"
/>
<Login v-else />
</div>
<!-- Show spash screen if ready is false -->
<Splash v-else />
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted, ref } from "vue";
import { IpcRendererEvent } from "electron";
import { useIpc } from "@/modules/ipc";
import Splash from "@/components/splash.vue";
import Messenger from "@/components/messenger.vue";
import Login from "@/components/login.vue";
export default defineComponent({
components: {
Splash,
Messenger,
Login
},
setup() {
const { post } = useIpc();
// Whether browser has received user info yet.
const ready = ref(false);
// Information about current user.
const profile = ref(false);
// New messages that browser missed while closed.
const newMessages = ref([])
// Receive updated information about the session.
const updateState = (_event: IpcRendererEvent, payload: any) => {
console.log("APP:Received updated profile and new messages: \n" +
` profile: ${payload.message.profile}\n` +
` new: ${payload.message.newMessages}`)
if (payload.message.profile) {
console.log(`APP:Logged-in, showing Messenger View.`)
} else {
console.log(`APP:Logged-out, showing Login View.`)
}
// Set profile and newMessages.
profile.value = payload.message.profile;
newMessages.value = payload.message.newMessages;
ready.value = true;
}
onMounted(() => {
console.log("APP:mounted.")
window.ipcRenderer.on("update-state", updateState)
window.ipcRenderer.on("update_available", () => {
console.log('testing auto update');
})
window.ipcRenderer.on("update_downloaded", () => {
console.log('testing update download');
})
post("app-mounted", "")
});
onUnmounted(() => {
window.ipcRenderer.removeAllListeners("update-state")
})
return {
ready,
profile,
post,
newMessages
}
}
})
</script>
<style lang="scss">
html, body {
margin: 0;
padding: 0;
// Background color set in window.ts
}
#app {
font-family: "SF Pro Text";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100vh;
width: 100vw;
border-radius: 15px;
}
#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

@ -1,43 +0,0 @@
const { ipcMain } = require("electron");
const { post } = require("./api");
const store = require("./utils/store");
const { launchSession, endSession } = require("./session");
const { backgroundMitt, ipcEmit } = require("./utils/emitter");
store.set("account", process.env.ACCOUNT);
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,38 +0,0 @@
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;

14
src/assets/crimata.svg Normal file
View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="13.795" height="12.759" viewBox="0 0 13.795 12.759">
<g id="Group_544" data-name="Group 544" transform="translate(-4127 -507)">
<g id="Group_33" data-name="Group 33" transform="translate(4127 507)">
<g id="Group_32" data-name="Group 32" transform="translate(0 0)">
<g id="Group_31" data-name="Group 31" transform="translate(0 6.627)">
<circle id="Ellipse_50" data-name="Ellipse 50" cx="3.066" cy="3.066" r="3.066" transform="translate(0 0)" fill="#383838"/>
<circle id="Ellipse_51" data-name="Ellipse 51" cx="3.066" cy="3.066" r="3.066" transform="translate(7.664 0)" fill="#383838"/>
</g>
<circle id="Ellipse_52" data-name="Ellipse 52" cx="3.066" cy="3.066" r="3.066" transform="translate(3.826 0)" fill="#383838"/>
<path id="Path_150" data-name="Path 150" d="M36.27,83.67" transform="translate(-33.199 -73.977)" fill="#ff0"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

View file

@ -1,134 +0,0 @@
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;

25
src/background.ts Normal file
View file

@ -0,0 +1,25 @@
/*
* Entry point for Crimata electron app.
* "Look on my Works, ye Mighty, and despair!"
*/
"use strict";
import { initApp } from './background/init';
import { protocol } from "electron";
// Scheme must be registered before the app is ready
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { secure: true, standard: true } }
]);
// Load environment variable
const isDev = require('electron-is-dev');
// NOTE Program Begins Here
(async () => {
console.log('Starting Crimata electron app.');
await initApp(isDev);
})();

188
src/background/audio.ts Normal file
View file

@ -0,0 +1,188 @@
/* eslint @typescript-eslint/no-var-requires: "off" */
"use strict";
import { ipcMain } from "electron";
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,
}
// Toggles record to true to begin capturing chunks.
const onRecordingStart = (_event: any, _payload: any) => {
console.log("AUDIO: Beginning audio capture.")
record = true;
}
// Returns recorded audio to frontend and sets record to false.
const onRecordingEnd = async (_event: any, payload: any) => {
console.log("AUDIO:Sending audio to browser.")
return new Promise((resolve, reject) => {
try {
resolve(audioContainer.input);
record = false;
} catch (e) {
reject()
}
});
};
// 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();
}
// Listen to record.
console.log("AUDIO:Adding recording listeners.")
ipcMain.removeAllListeners("start-recording");
ipcMain.on("start-recording", onRecordingStart);
ipcMain.removeHandler("stop-recording");
ipcMain.handle("stop-recording", onRecordingEnd);
}
// ---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;
}
}
}

62
src/background/helpers.ts Normal file
View file

@ -0,0 +1,62 @@
import fs from 'fs';
import { backgroundMitt } from "@/modules/emitter";
import { SessionState, WindowState } from "@/types";
import { app } from "electron";
const configPath = app.getPath("userData");
export const ipcEmit = (channel: string, payload: any) => {
backgroundMitt.emit('ipc-renderer', {
endpoint: channel,
message: payload
});
}
export const loadState = (fileName: string): SessionState => {
let state: SessionState;
try {
state = JSON.parse(fs.readFileSync(configPath + fileName).toString());
}
catch (error) {
state = {
key: false,
newMessages: []
}
}
return state
}
export const loadWinState = (fileName: string): WindowState => {
let state: WindowState;
try {
state = JSON.parse(fs.readFileSync(configPath + fileName).toString());
}
catch (error) {
state = {
width: 600,
height: 500,
x: null,
y: null,
}
}
return state
}
// Save session or window state.
export const saveToJson = (fileName: string, data: any) => {
fs.writeFile(configPath + fileName, JSON.stringify(data), (err) => {
if (err) {
console.log("Error when saving to json.")
}
})
}

95
src/background/init.ts Normal file
View file

@ -0,0 +1,95 @@
"use strict";
import { app, dialog, ipcMain } from "electron";
import { createWindow } from './window';
import { initSession } from './session';
import { initAudioIO, stopStream } from './audio';
import { backgroundMitt } from '@/modules/emitter';
let win: boolean;
// Listen for window creation.
backgroundMitt.on('window-active', (state: boolean) => {
win = state;
});
// Auto updating.
const { autoUpdater } = require('electron-updater')
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()
})
})
// Run when electron app is initialized.
async function main() {
console.log("MAIN:Initializing Electron App.")
ipcMain.removeHandler('version');
ipcMain.handle('version', (_event: any, _payload: any) => {
return app.getVersion();
})
// Must wait til window is created.
await createWindow();
// Instantiate socket session with crimata-platorm.
initSession();
// Begin audio stream.
initAudioIO();
}
// Root function of app.
export function initApp(dev: boolean): void {
// On initial startup.
app.on("ready", () => {
autoUpdater.checkForUpdates()
main()
});
// Must keep to ensure app doesn't quit on close.
app.on("before-quit", async () => {
await stopStream();
});
// 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 (dev) {
process.on("SIGTERM", () => {
app.quit();
});
}
}

166
src/background/session.ts Normal file
View file

@ -0,0 +1,166 @@
/*
* Creates a websocket session with Crimata Servers.
*
* Connects to Servers and attempts key authentication. Server will respond
* with key and user profile. We send the profile to the browser. We also
* resend this information on new broser window. We then serve as a
* communication interface between the window and the servers. It will
* automatically try to reconnect on websocket disconnect.
*/
import { backgroundMitt } from "@/modules/emitter";
import { ipcEmit, loadState, saveToJson } from './helpers';
import { ipcMain, IpcMainEvent, IpcMainInvokeEvent } from "electron";
import useWebSockets from "@/modules/websockets";
import { play } from "./audio";
import { renderMessage } from "@/modules/message";
import { AuthProtocol, SessionState, Profile } from "@/types";
let win = true;
// Info saved to json on quit (key, newMessages).
let state: SessionState;
// Profile of current user.
let profile: Profile | boolean;
// Called when server sends auth message.
const updateState = (res: AuthProtocol) => {
console.log("SESS:Auth message received: \n" +
` key: ${res.key}\n` +
` alias: ${res.profile}`)
if (state) {
// Update key.
state.key = res.key;
// Update the user profile.
profile = res.profile;
// Send upated profile to frontend.
console.log("SESS:Sending updated user profile to browser.")
ipcEmit("update-state", {
profile: profile,
newMessages: state.newMessages
})
// Save the updated state to json.
console.log("SESS:Saving session state.")
saveToJson("session.json", state)
}
}
// Send state on new window.
const onNewBrowserWindow = (_event: IpcMainInvokeEvent, _payload: any) => {
if (typeof profile !== 'undefined') {
console.log("SESS:Sending user profile to browser.")
ipcEmit("update-state", {
profile: profile,
newMessages: state.newMessages
})
}
}
// Calls appropriate endpoint for a server message.
const onMessage = (data: string) => {
let message = JSON.parse(data)
// AuthProtocol message.
if (message.hasOwnProperty("key")) {
updateState(message)
}
// Standard message.
else if (message.content) {
// Convert to render message
message = renderMessage(
message.content.text,
message.content.audio,
message.context,
message.modifier
);
if (win) {
console.log("SESS:Emitting standard message.")
if (message.audio) {
play(message.audio)
}
ipcEmit("render-message", message)
}
else {
console.log("SESS:No window: saving message.")
state.newMessages.push(message);
}
}
else {
if (win) {
ipcEmit("render-message", message)
}
}
};
// When socket connects, we update state.
const onOpen = () => {
console.log(`SESS:Sending key: ${state.key}`)
if (state) {
sendMessage({
"key": state.key,
"usr": false,
"pwd": false
})
}
}
// Websockets module.
const { createSocket, sendMessage } = useWebSockets(onMessage, onOpen);
// Handle messages from window/client.
const onClientMessage = (_event: IpcMainEvent, payload: any) => {
console.log("New client message")
const success = sendMessage(payload)
if (!success) {
console.log("Unable to send message: ", payload)
}
}
// Call this to initialize session with Crimata servers.
export const initSession = () => {
console.log("SESS:Creating new session.")
// Load Json or createState.
state = loadState("session.json")
console.log("SESS:State loaded: \n" +
` key: ${state.key}\n` +
` new: ${state.newMessages}`)
// Open socket connection.
createSocket()
// Attack browser window init listener.
ipcMain.removeAllListeners("app-mounted")
ipcMain.on("app-mounted", onNewBrowserWindow);
// Attach listeners for frontend.
ipcMain.removeAllListeners("client-message")
ipcMain.on("client-message", onClientMessage);
// Keep win up-to-date.
backgroundMitt.on('window-active', (state: boolean) => {
win = state;
});
}

151
src/background/window.ts Normal file
View file

@ -0,0 +1,151 @@
"use strict";
import { BrowserWindow, ipcMain } from "electron";
import { createProtocol } from "vue-cli-plugin-electron-builder/lib";
import { backgroundMitt } from '@/modules/emitter';
import { RenderMessage, WindowState } from "@/types";
import { loadWinState, saveToJson } from "./helpers";
import * as path from "path";
const { autoUpdater } = require('electron-updater');
interface IpcRendererPayload {
endpoint: string;
message: RenderMessage | null;
}
let win: BrowserWindow | null;
let winState: WindowState;
// Called when a NavBar button is pressed.
const onNavBar = (_event: any, action: string): void => {
if (win) {
if (action === "close") {
win.close()
} else {
win.minimize()
}
}
}
// Util function to render message on ipc-renderer event.
const renderMessage = (payload: IpcRendererPayload): void => {
if (win) {
win.webContents.send(payload.endpoint, {
message: payload.message
});
}
}
// Write a json with position and size of window.
const saveWindowState = () => {
if (win) {
const bounds = win.getBounds();
const position = win.getPosition();
if (winState) {
winState.width = bounds.width;
winState.height = bounds.height;
winState.x = position[0];
winState.y = position[1]
saveToJson("window.json", winState)
}
}
}
// Do this on window mount.
const onWindowMount = (): void => {
console.log("BW:Adding window listeners. ")
// Must tell initApp that window exists.
backgroundMitt.emit('window-active', true);
// Handle win nav-bar event.
ipcMain.removeAllListeners("nav-bar") // avoid setting duplicate handlers
ipcMain.on("nav-bar", onNavBar);
// Gateway for messages to the frontend.
backgroundMitt.removeAllListeners("ipc-renderer")
backgroundMitt.on("ipc-renderer", renderMessage);
console.log("BW:Listeners created.")
}
// Do this on window dismount (close).
const onWindowDismount = (): void => {
console.log("BW:Window closed.")
win = null;
backgroundMitt.emit('window-active', false);
}
// function used by run.ts to create the main window.
export async function createWindow(): Promise<void> {
return new Promise((resolve, _reject) => {
// avoid creating duplicate windows.
if (win) resolve();
// Load the saved window state.
winState = loadWinState("window.json")
console.log(`BW:Creating window [${winState.width}, ${winState.height}].`)
// Define the browser window.
win = new BrowserWindow({
width: winState.width,
height: winState.height,
x: winState.x,
y: winState.y,
resizable: true,
backgroundColor: '#EBEBEB',
frame: false,
minWidth: 350,
minHeight: 500,
webPreferences: {
nodeIntegration: (process.env.ELECTRON_NODE_INTEGRATION as unknown) as boolean, preload: path.join(__dirname, "preload.js")
}
});
// Load the URL.
if (process.env.WEBPACK_DEV_SERVER_URL) {
win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string); // dev
}
else {
createProtocol("app");
win.loadURL("app://./index.html"); // prod
}
// Handle window close.
win.on("closed", onWindowDismount);
// Save window state on resize and move.
win.on("moved", saveWindowState);
win.on("resize", saveWindowState);
win.once('ready-to-show', () => {
autoUpdater.checkForUpdatesAndNotify();
if (win) win.show()
})
// For showing/hiding the splash screen.
win.webContents.on('did-finish-load', () => {
if (win) win.webContents.send('window-ready', {
message: true
});
onWindowMount();
resolve();
});
});
}
autoUpdater.on('update-available', () => {
if (win) win.webContents.send('update_available');
});
autoUpdater.on('update-downloaded', () => {
if (win) win.webContents.send('update_downloaded');
});

View file

@ -1,13 +0,0 @@
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

@ -0,0 +1,111 @@
import anime from "animejs";
import useMitt from "@/modules/mitt";
import { useIpc } from '@/modules/ipc';
import { onMounted, onUnmounted, ref, Ref } from "vue";
import keyboardNameMap from "../keyBoardMaps/keyboardNameMap";
import { renderMessage, clientMessage } from '@/modules/message';
function showRecIcon () {
anime({
targets: '#recIcon',
opacity: [0, 0.75],
scale: [0.0, 1],
duration: 250,
easing: 'linear',
})
}
function hideRecIcon () {
anime({
targets: '#recIcon',
opacity: [0.75, 0],
scale: [1, 0],
duration: 250,
easing: 'linear',
})
}
export default function useAudioInputController (typing: Ref) {
// For sending messages.
const { post, invoke } = useIpc();
const { emitter } = useMitt();
// Keepp track of when we are recording.
const recording = ref(false);
//---Callbacks-----------------------------------------------
const onKeyDown = (e: KeyboardEvent) => {
const cmd = keyboardNameMap[e.keyCode];
// console.log(cmd)
// Start recording on space bar.
if (cmd == "SPACE" && !recording.value && !typing.value) {
console.log("INPT:Starting record.")
post("start-recording", "");
showRecIcon()
recording.value = true;
}
}
const onKeyUp = async (e: KeyboardEvent) => {
const cmd = keyboardNameMap[e.keyCode];
// Stop recording on space up.
if (cmd == "SPACE" && recording.value) {
// Create a message.
const message = renderMessage(
"",
"",
"",
"sf"
)
// Render it immediately.
emitter.emit("self-message", message);
// Stop recording and get audio from recorder.
console.log("INPT:Stopping record.")
const audio = await invoke("stop-recording", "");
// Send message to the backend for processing.
const clientM = clientMessage("", audio, message.uid);
post('client-message', clientM);
hideRecIcon()
recording.value = false;
}
}
//-----------------------------------------------------------
onMounted(() => {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
})
onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
});
return {
recording
}
}

View file

@ -0,0 +1,192 @@
import anime from "animejs";
import useMitt from "@/modules/mitt";
import { useIpc } from '@/modules/ipc';
import { Ref, ref, watch, onMounted, onUnmounted } from "vue";
import keyboardNameMap from "../keyBoardMaps/keyboardNameMap";
import { clientMessage, renderMessage } from '@/modules/message';
//---Animations-----------------------------------------------
let side = "right"; // Side of parent we are on.
function showTextInput () {
const t1 = (side === "right") ? 50 : -70;
const t2 = (side === "right") ? 110 : -130;
anime({
targets: '#textInput',
opacity: [0, 1],
translateX: [t1, t2],
scale: [0.3, 1],
duration: 500,
easing: 'easeOutExpo',
})
}
function hideTextInput() {
const t = (side === "right") ? 50 : -80;
anime({
targets: '#textInput',
opacity: [1, 0],
translateX: t,
scale: 0.3,
duration: 500,
easing: 'easeOutExpo',
})
}
function switchSide(currentSide: string) {
const t = (currentSide === "right") ? -130 : 110;
anime({
targets: '#textInput',
translateX: t,
duration: 500,
easing: 'easeOutExpo',
})
}
//------------------------------------------------------------
export default function useTextInputController(elementX: Ref) {
let textInput: HTMLInputElement | null;
const { post } = useIpc();
const { emitter } = useMitt();
let firstKey = true;
const typing = ref(false);
// Prep inputItem for typing.
const prepInput = () => {
showTextInput()
typing.value = true
}
// Clear and hide inputItem after done typing.
const clearInput = () => {
if (textInput) {
textInput.value = "";
textInput.blur();
}
hideTextInput()
firstKey = true;
typing.value = false;
}
// Send a message and clean up after.
const sendMessage = () => {
if (textInput) {
// Create the message.
const message = renderMessage(
textInput.value,
false,
"",
"sf"
)
emitter.emit("self-message", message);
// Send it to the backend for processing.
const clientM = clientMessage(textInput.value, false, message.uid);
post('client-message', clientM);
clearInput()
}
}
// Keys that are capable of opening the text input (numbers and letters).
const hotKeyRange = keyboardNameMap.slice(47, 91)
const isHotKey = (key: string) => {
if (hotKeyRange.includes(key)) {
return true
}
}
//---Callbacks-----------------------------------------------
const onKeyDown = (e: KeyboardEvent) => {
const key = keyboardNameMap[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 === "BACK_SPACE")) {
clearInput()
return
}
if (key === "ESCAPE") {
clearInput()
return
}
// Close and send on enter.
if (key === "ENTER") {
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 === "right") {
if (winW - elementX < 230) {
switchSide(side)
side = "left"
}
}
else {
if (winW - elementX > 230) {
switchSide(side)
side = "right"
}
}
});
onMounted(() => {
textInput = document.getElementById("textInput") as HTMLInputElement;
window.addEventListener("keydown", onKeyDown);
})
onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown);
});
return {
typing
}
}

View file

@ -0,0 +1,194 @@
<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 -->
<TextInput />
<!-- Show suggestions menu on click -->
<!-- <Menu /> -->
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import draggify from "@/modules/draggify";
import TextInput from "@/components/textInput.vue";
import useTextInputController from
"@/components/controllers/textCtrl";
import useAudioInputController from
"@/components/controllers/audioCtrl";
export default defineComponent({
name: "InputItem",
props: ["initials"],
components: {
TextInput
},
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);
}
}
</style>

View file

@ -0,0 +1,263 @@
// This has the UnShifted and Shifted characters that each key maps to
// Ones that are to be ignored for character input are empty.
const keyboardCharMap = [
["", ""], // [0]
["", ""], // [1]
["", ""], // [2]
["", ""], // [3]
["", ""], // [4]
["", ""], // [5]
["", ""], // [6]
["", ""], // [7]
["", ""], // [8]
["", ""], // [9]
["", ""], // [10]
["", ""], // [11]
["", ""], // [12]
["\r", "\r"], // [13] - MOST control characters are ignored. This one (Carriage Return, or "Enter") is significant!
["", ""], // [14]
["", ""], // [15]
["", ""], // [16]
["", ""], // [17]
["", ""], // [18]
["", ""], // [19]
["", ""], // [20]
["", ""], // [21]
["", ""], // [22]
["", ""], // [23]
["", ""], // [24]
["", ""], // [25]
["", ""], // [26]
["", ""], // [27]
["", ""], // [28]
["", ""], // [29]
["", ""], // [30]
["", ""], // [31]
[" ", " "], // [32] // SPACE! Don't "clean it up" and remove the space!
["", ""], // [33]
["", ""], // [34]
["", ""], // [35]
["", ""], // [36]
["", ""], // [37]
["", ""], // [38]
["", ""], // [39]
["", ""], // [40]
["", ""], // [41]
["", ""], // [42]
["", ""], // [43]
["", ""], // [44]
["", ""], // [45]
["", ""], // [46]
["", ""], // [47]
["0", ")"], // [48]
["1", "!"], // [49]
["2", "@"], // [50]
["3", "#"], // [51]
["4", "$"], // [52]
["5", "%"], // [53]
["6", "^"], // [54]
["7", "&"], // [55]
["8", "*"], // [56]
["9", "("], // [57]
["", ""], // [58]
[";", ":"], // [59]
["<", ""], // [60]
["=", ""], // [61]
[">", ""], // [62]
["?", ""], // [63] shifted; else "/"
["", ""], // [64]
["a", "A"], // [65]
["b", "B"], // [66]
["c", "C"], // [67]
["d", "D"], // [68]
["e", "E"], // [69]
["f", "F"], // [70]
["g", "G"], // [71]
["h", "H"], // [72]
["i", "I"], // [73]
["j", "J"], // [74]
["k", "K"], // [75]
["l", "L"], // [76]
["m", "M"], // [77]
["n", "N"], // [78]
["o", "O"], // [79]
["p", "P"], // [80]
["q", "Q"], // [81]
["r", "R"], // [82]
["s", "S"], // [83]
["t", "T"], // [84]
["u", "U"], // [85]
["v", "V"], // [86]
["w", "W"], // [87]
["x", "X"], // [88]
["y", "Y"], // [89]
["z", "Z"], // [90]
["", ""], // [91] Windows Key (Windows) or Command Key (Mac)
["", ""], // [92]
["", ""], // [93]
["", ""], // [94]
["", ""], // [95]
// Number Keypad Entries...
["0", ""], // [96]
["1", ""], // [97]
["2", ""], // [98]
["3", ""], // [99]
["4", ""], // [100]
["5", ""], // [101]
["6", ""], // [102]
["7", ""], // [103]
["8", ""], // [104]
["9", ""], // [105]
["*", ""], // [106]
["+", ""], // [107]
["", ""], // [108]
["-", ""], // [109]
[".", ""], // [110]
["/", ""], // [111]
["", ""], // [112]
["", ""], // [113]
["", ""], // [114]
["", ""], // [115]
["", ""], // [116]
["", ""], // [117]
["", ""], // [118]
["", ""], // [119]
["", ""], // [120]
["", ""], // [121]
["", ""], // [122]
["", ""], // [123]
["", ""], // [124]
["", ""], // [125]
["", ""], // [126]
["", ""], // [127]
["", ""], // [128]
["", ""], // [129]
["", ""], // [130]
["", ""], // [131]
["", ""], // [132]
["", ""], // [133]
["", ""], // [134]
["", ""], // [135]
["", ""], // [136]
["", ""], // [137]
["", ""], // [138]
["", ""], // [139]
["", ""], // [140]
["", ""], // [141]
["", ""], // [142]
["", ""], // [143]
["", ""], // [144]
["", ""], // [145]
["", ""], // [146]
["", ""], // [147]
["", ""], // [148]
["", ""], // [149]
["", ""], // [150]
["", ""], // [151]
["", ""], // [152]
["", ""], // [153]
["", ""], // [154]
["", ""], // [155]
["", ""], // [156]
["", ""], // [157]
["", ""], // [158]
["", ""], // [159]
["", ""], // [160]
["", ""], // [161]
["", ""], // [162]
["", ""], // [163]
["", ""], // [164]
["", ""], // [165]
["", ""], // [166]
["", ""], // [167]
["", ""], // [168]
["", ""], // [169]
["", ""], // [170]
["", ""], // [171]
["", ""], // [172]
["", ""], // [173]
["", ""], // [174]
["", ""], // [175]
["", ""], // [176]
["", ""], // [177]
["", ""], // [178]
["", ""], // [179]
["", ""], // [180]
["", ""], // [181]
["", ""], // [182]
["", ""], // [183]
["", ""], // [184]
["", ""], // [185]
[";", ":"], // [186]
["=", "+"], // [187]
[",", "<"], // [188]
["-", "_"], // [189]
[".", ">"], // [190]
["/", "?"], // [191]
["`", "~"], // [192]
["", ""], // [193]
["", ""], // [194]
["", ""], // [195]
["", ""], // [196]
["", ""], // [197]
["", ""], // [198]
["", ""], // [199]
["", ""], // [200]
["", ""], // [201]
["", ""], // [202]
["", ""], // [203]
["", ""], // [204]
["", ""], // [205]
["", ""], // [206]
["", ""], // [207]
["", ""], // [208]
["", ""], // [209]
["", ""], // [210]
["", ""], // [211]
["", ""], // [212]
["", ""], // [213]
["", ""], // [214]
["", ""], // [215]
["", ""], // [216]
["", ""], // [217]
["", ""], // [218]
["[", "{"], // [219]
["\\", "|"], // [220]
["]", "}"], // [221]
["'", '"'], // [222]
["", ""], // [223]
["", ""], // [224]
["", ""], // [225]
["", ""], // [226]
["", ""], // [227]
["", ""], // [228]
["", ""], // [229]
["", ""], // [230]
["", ""], // [231]
["", ""], // [232]
["", ""], // [233]
["", ""], // [234]
["", ""], // [235]
["", ""], // [236]
["", ""], // [237]
["", ""], // [238]
["", ""], // [239]
["", ""], // [240]
["", ""], // [241]
["", ""], // [242]
["", ""], // [243]
["", ""], // [244]
["", ""], // [245]
["", ""], // [246]
["", ""], // [247]
["", ""], // [248]
["", ""], // [249]
["", ""], // [250]
["", ""], // [251]
["", ""], // [252]
["", ""], // [253]
["", ""], // [254]
["", ""] // [255]
];
export default keyboardCharMap;

View file

@ -0,0 +1,261 @@
// names of known key codes (0-255)
const keyboardNameMap = [
"", // [0]
"", // [1]
"", // [2]
"CANCEL", // [3]
"", // [4]
"", // [5]
"HELP", // [6]
"", // [7]
"BACK_SPACE", // [8]
"TAB", // [9]
"", // [10]
"", // [11]
"CLEAR", // [12]
"ENTER", // [13]
"ENTER_SPECIAL", // [14]
"", // [15]
"SHIFT", // [16]
"CONTROL", // [17]
"ALT", // [18]
"PAUSE", // [19]
"CAPS_LOCK", // [20]
"KANA", // [21]
"EISU", // [22]
"JUNJA", // [23]
"FINAL", // [24]
"HANJA", // [25]
"", // [26]
"ESCAPE", // [27]
"CONVERT", // [28]
"NONCONVERT", // [29]
"ACCEPT", // [30]
"MODECHANGE", // [31]
"SPACE", // [32]
"PAGE_UP", // [33]
"PAGE_DOWN", // [34]
"END", // [35]
"HOME", // [36]
"LEFT", // [37]
"UP", // [38]
"RIGHT", // [39]
"DOWN", // [40]
"SELECT", // [41]
"PRINT", // [42]
"EXECUTE", // [43]
"PRINTSCREEN", // [44]
"INSERT", // [45]
"DELETE", // [46]
"", // [47]
"0", // [48]
"1", // [49]
"2", // [50]
"3", // [51]
"4", // [52]
"5", // [53]
"6", // [54]
"7", // [55]
"8", // [56]
"9", // [57]
"COLON", // [58]
"SEMICOLON", // [59]
"LESS_THAN", // [60]
"EQUALS", // [61]
"GREATER_THAN", // [62]
"QUESTION_MARK", // [63]
"AT", // [64]
"A", // [65]
"B", // [66]
"C", // [67]
"D", // [68]
"E", // [69]
"F", // [70]
"G", // [71]
"H", // [72]
"I", // [73]
"J", // [74]
"K", // [75]
"L", // [76]
"M", // [77]
"N", // [78]
"O", // [79]
"P", // [80]
"Q", // [81]
"R", // [82]
"S", // [83]
"T", // [84]
"U", // [85]
"V", // [86]
"W", // [87]
"X", // [88]
"Y", // [89]
"Z", // [90]
"OS_KEY", // [91] Windows Key (Windows) or Command Key (Mac)
"", // [92]
"CONTEXT_MENU", // [93]
"", // [94]
"SLEEP", // [95]
"NUMPAD0", // [96]
"NUMPAD1", // [97]
"NUMPAD2", // [98]
"NUMPAD3", // [99]
"NUMPAD4", // [100]
"NUMPAD5", // [101]
"NUMPAD6", // [102]
"NUMPAD7", // [103]
"NUMPAD8", // [104]
"NUMPAD9", // [105]
"MULTIPLY", // [106]
"ADD", // [107]
"SEPARATOR", // [108]
"SUBTRACT", // [109]
"DECIMAL", // [110]
"DIVIDE", // [111]
"F1", // [112]
"F2", // [113]
"F3", // [114]
"F4", // [115]
"F5", // [116]
"F6", // [117]
"F7", // [118]
"F8", // [119]
"F9", // [120]
"F10", // [121]
"F11", // [122]
"F12", // [123]
"F13", // [124]
"F14", // [125]
"F15", // [126]
"F16", // [127]
"F17", // [128]
"F18", // [129]
"F19", // [130]
"F20", // [131]
"F21", // [132]
"F22", // [133]
"F23", // [134]
"F24", // [135]
"", // [136]
"", // [137]
"", // [138]
"", // [139]
"", // [140]
"", // [141]
"", // [142]
"", // [143]
"NUM_LOCK", // [144]
"SCROLL_LOCK", // [145]
"WIN_OEM_FJ_JISHO", // [146]
"WIN_OEM_FJ_MASSHOU", // [147]
"WIN_OEM_FJ_TOUROKU", // [148]
"WIN_OEM_FJ_LOYA", // [149]
"WIN_OEM_FJ_ROYA", // [150]
"", // [151]
"", // [152]
"", // [153]
"", // [154]
"", // [155]
"", // [156]
"", // [157]
"", // [158]
"", // [159]
"CIRCUMFLEX", // [160]
"EXCLAMATION", // [161]
"DOUBLE_QUOTE", // [162]
"HASH", // [163]
"DOLLAR", // [164]
"PERCENT", // [165]
"AMPERSAND", // [166]
"UNDERSCORE", // [167]
"OPEN_PAREN", // [168]
"CLOSE_PAREN", // [169]
"ASTERISK", // [170]
"PLUS", // [171]
"PIPE", // [172]
"HYPHEN_MINUS", // [173]
"OPEN_CURLY_BRACKET", // [174]
"CLOSE_CURLY_BRACKET", // [175]
"TILDE", // [176]
"", // [177]
"", // [178]
"", // [179]
"", // [180]
"VOLUME_MUTE", // [181]
"VOLUME_DOWN", // [182]
"VOLUME_UP", // [183]
"", // [184]
"", // [185]
"SEMICOLON", // [186]
"EQUALS", // [187]
"COMMA", // [188]
"MINUS", // [189]
"PERIOD", // [190]
"SLASH", // [191]
"BACK_QUOTE", // [192]
"", // [193]
"", // [194]
"", // [195]
"", // [196]
"", // [197]
"", // [198]
"", // [199]
"", // [200]
"", // [201]
"", // [202]
"", // [203]
"", // [204]
"", // [205]
"", // [206]
"", // [207]
"", // [208]
"", // [209]
"", // [210]
"", // [211]
"", // [212]
"", // [213]
"", // [214]
"", // [215]
"", // [216]
"", // [217]
"", // [218]
"OPEN_BRACKET", // [219]
"BACK_SLASH", // [220]
"CLOSE_BRACKET", // [221]
"QUOTE", // [222]
"", // [223]
"META", // [224]
"ALTGR", // [225]
"", // [226]
"WIN_ICO_HELP", // [227]
"WIN_ICO_00", // [228]
"", // [229]
"WIN_ICO_CLEAR", // [230]
"", // [231]
"", // [232]
"WIN_OEM_RESET", // [233]
"WIN_OEM_JUMP", // [234]
"WIN_OEM_PA1", // [235]
"WIN_OEM_PA2", // [236]
"WIN_OEM_PA3", // [237]
"WIN_OEM_WSCTRL", // [238]
"WIN_OEM_CUSEL", // [239]
"WIN_OEM_ATTN", // [240]
"WIN_OEM_FINISH", // [241]
"WIN_OEM_COPY", // [242]
"WIN_OEM_AUTO", // [243]
"WIN_OEM_ENLW", // [244]
"WIN_OEM_BACKTAB", // [245]
"ATTN", // [246]
"CRSEL", // [247]
"EXSEL", // [248]
"EREOF", // [249]
"PLAY", // [250]
"ZOOM", // [251]
"", // [252]
"PA1", // [253]
"WIN_OEM_CLEAR", // [254]
"" // [255]
];
export default keyboardNameMap;

169
src/components/login.vue Normal file
View file

@ -0,0 +1,169 @@
<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 { useIpc } from "@/modules/ipc";
import { authRequest } from '@/modules/message';
export default defineComponent({
name: "Login",
setup() {
const { post } = useIpc();
const usr = ref("");
const pwd = ref("");
// Submit login credentials to the backend.
const submitForm = () => {
console.log(`Submitting login form: ${usr.value}, ${pwd.value}`)
post("client-message", authRequest(false, usr.value, pwd.value))
}
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>

419
src/components/message.vue Normal file
View file

@ -0,0 +1,419 @@
<template>
<div
class="message"
:class="`${message.isChild}ChildMessage`"
:id="`${message.modifier}Message`"
:key="message.id"
>
<!-- Three types of messages: self (sf), friend (fr), and crimata (ai) -->
<!-- !denoted by the modifier attribute -->
<!-- Shows when a new session is created with Crimata. -->
<div
v-if="message.context === 'Crimata'"
class="divider"
>
<div>new session</div>
</div>
<!-- Includes bubble and context. -->
<div
:id="`${message.modifier}MessageBox`"
class="messageBox"
>
<!-- message bubble -->
<div
class="bubble"
:class="`${message.modifier}-${message.isChild}ChildBubble`"
:id="`${message.modifier}Bubble`"
>
<!-- Notification dot for new messages. -->
<div v-if="notify === true"
class="notify"
/>
<!-- Show loader when audio is being transcribed. -->
<div
v-if="message.content.text === ''"
class="contentLoader"
>
<div class="contentLoaderDot"></div>
<div class="contentLoaderDot"></div>
<div class="contentLoaderDot"></div>
</div>
<!-- Show question mark if transcription comes back as unknown. -->
<div
v-else-if="message.content.text === false"
class="questionMark"
>
?
</div>
<!-- Else, show text. -->
<div v-else>
{{ message.content.text }}
</div>
</div>
<!-- message context -->
<span
v-if="message.isChild === 'last' || message.isChild === 'none'"
class="context"
>
<!-- Render Crimata icon or friend's initials. -->
<div v-if="message.modifier == 'ai'" class="photo">
<img src="@/assets/crimata.svg"/>
</div>
<!-- Render Crimata icon or friend's initials. -->
<div v-if="message.modifier == 'fr'" class="photo"
:class="{ playing: isPlaying }"
>
{{ message.context[0] }}
</div>
{{ message.context }}
</span>
</div>
</div>
</template>
<script lang='ts'>
import { defineComponent, ref, onMounted } from 'vue';
export default defineComponent({
name: "MessageItem",
props: ["message"],
setup(props) {
let addListener = false;
const notify = ref(false)
const isPlaying = ref(false);
if (!props.message.seen) {
console.log("MSG:Checking...")
// newMessages always render with blue dot.
if (props.message.newMessage) {
console.log("MSG:New message, notifying.")
props.message.seen = true;
notify.value = true;
}
else {
// Do nothing if window is visible on setup.
if (document.visibilityState === "visible") {
props.message.seen = true;
}
// We must listen for window visible event.
else {
console.log("MSG:Unseen, adding listener.")
addListener = true;
}
}
}
// Callback for window visibilityState.
const onVisibilityChange = (_event: any) => {
if (document.visibilityState === "visible") {
console.log("MSG:Window visible, notifying.")
props.message.seen = true;
notify.value = true; // triggers blue dot anim.
// Once complete we can remove.
document.removeEventListener("visibilitychange", onVisibilityChange)
}
}
const onStopPlayback = (_event: any) => {
window.ipcRenderer.removeAllListeners("stop-playback-anim");
isPlaying.value = false
}
onMounted(() => {
// Listen for window focus event.
if (addListener) {
document.addEventListener("visibilitychange", onVisibilityChange)
}
// Turn on audio playback animation if audio.
if (props.message.content.audio === true) {
window.ipcRenderer.on("stop-playback-anim", onStopPlayback);
isPlaying.value = true
}
})
return {
notify,
isPlaying
}
}
})
</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;
}
.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;
}
.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;
}
</style>

View file

@ -0,0 +1,115 @@
<template>
<InputItem :initials="profile.initials"/>
<Settings />
<div id="recIcon" />
<!-- List of message bubbles. -->
<div id="messenger">
<Message
v-for="message in messages"
:message="message[1]"
:key="message[0]"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted } from "vue";
import Message from "@/components/message.vue";
import InputItem from "@/components/inputItem.vue";
import Settings from "@/components/settings.vue";
import useMitt from "@/modules/mitt";
import useMessages from "@/modules/messages";
export default defineComponent({
name: "Messenger",
props: ["profile", "newMessages"],
components: {
Message,
InputItem,
Settings
},
setup(props) {
// Listen for self-messages from inputItem.
const { emitter } = useMitt();
// Handle messages in view.
const { messages, prepMessageView, updateMessageView } = useMessages();
onMounted(() => {
const crimataId = props.profile.crimataId;
console.log(`MSGR:Initializing messenger for ${crimataId}.`)
// Load the message history.
if (crimataId === window.localStorage.getItem("last_usr")) {
prepMessageView(props.newMessages)
} else {
messages.value.clear()
}
// New content listeners.
emitter.on('self-message', (message) => updateMessageView(message));
window.ipcRenderer.on("render-message", (_e: any, payload: any) => {
updateMessageView(payload.message)
});
// Save the usr for next time.
window.localStorage.setItem("last_usr", crimataId)
});
onUnmounted(() => {
window.ipcRenderer.removeAllListeners("render-message");
window.ipcRenderer.removeAllListeners("annotate-message");
emitter.all.clear();
});
return {
messages
};
},
});
</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>

163
src/components/settings.vue Normal file
View file

@ -0,0 +1,163 @@
<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, onMounted } from "vue";
import { useIpc } from "@/modules/ipc";
import { logoutRequest } from '@/modules/message';
export default defineComponent({
name: "Settings",
setup() {
const toggleSettings = ref(false);
const { post, invoke } = useIpc();
// 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 = () => {
console.log("Submitting logout request.")
post("client-message", logoutRequest())
}
onMounted(async () => {
const version = await invoke('version', true);
console.log(version);
})
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>

69
src/components/splash.vue Normal file
View file

@ -0,0 +1,69 @@
<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,49 @@
<template>
<input
id="textInput"
type="text"
/>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "TextInput",
})
</script>
<style lang="scss" scoped>
#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

@ -1,11 +0,0 @@
const { app } = require("electron");
const DOMAIN = "crimata.com";
/* If PROD env variable exists */
const prod = process.env.PROD;
module.exports = {
PLATFORM: prod ? `https://app.${DOMAIN}` : `http://localhost:5000`,
API: prod ? `https://${DOMAIN}/api` : `http://localhost:5001`
}

View file

@ -1,69 +0,0 @@
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 @@
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());

15
src/main.ts Normal file
View file

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

199
src/modules/draggify.ts Normal file
View file

@ -0,0 +1,199 @@
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
}
}

13
src/modules/emitter.ts Normal file
View file

@ -0,0 +1,13 @@
/* eslint-disable */
import { Emitter } from "mitt";
// Backend emitter
type Mitt = Emitter;
const EventEmitter = require('events');
class BackgroundMitt extends EventEmitter { }
export const backgroundMitt = new BackgroundMitt();

21
src/modules/ipc.ts Normal file
View file

@ -0,0 +1,21 @@
export const useIpc = () => {
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
}
}

62
src/modules/message.ts Normal file
View file

@ -0,0 +1,62 @@
import {
RenderMessage,
ClientMessage,
ClientRequest,
AuthRequest,
LogoutRequest
} from "@/types";
import { v4 as uuidv4 } from 'uuid';
function getTimeStamp(): number {
const currentdate = new Date();
return currentdate.getTime();
}
// Create a RenderMessage object.
export const renderMessage = (text: boolean | string, audio: boolean | string, context: string, modifier: string): RenderMessage => (
{
content: {
text: text,
audio: audio
},
context: context,
modifier: modifier,
time: getTimeStamp(),
uid: uuidv4(),
isChild: "none",
seen: false,
newMessage: false
}
)
export const clientMessage = (text: string, audio: string | boolean, uid: string): ClientMessage => (
{
audio,
text,
uid
}
)
export const clientRequest = (intent: string, params: object, epic: string | boolean): ClientRequest => (
{
intent: intent,
params: params,
epic: epic,
confidence: 1.0
}
)
export const authRequest = (key: boolean | string, usr: boolean | string, pwd: boolean | string): AuthRequest => (
{
key,
usr,
pwd
}
)
export const logoutRequest = (): LogoutRequest => (
{
logout: true
}
)

125
src/modules/messages.ts Normal file
View file

@ -0,0 +1,125 @@
import { ref } from 'vue';
import useScroll from "@/modules/scroll";
import { RenderMessage, Annotation } from "@/types";
const messages = ref(new Map());
const addMessage = (message: RenderMessage) => {
messages.value.set(message.uid, message)
}
const updateMessage = (annotation: Annotation) => {
const message = messages.value.get(annotation.uid)
message.context = annotation.context
message.content.text = annotation.text
}
const loadSavedMessages = () => {
const rawData = window.localStorage.getItem("crimata_messages");
if (rawData) {
const messageData = JSON.parse(rawData)
messages.value = new Map(Object.entries(messageData));
}
}
const saveMessages = () => {
const messageData = Object.fromEntries(messages.value);
window.localStorage.setItem("crimata_messages", JSON.stringify(messageData));
}
const isSimmilar = (messageA: RenderMessage, messageB: RenderMessage) => {
if ((Math.abs(messageA.time - messageB.time) < 20000) && (messageA.modifier == messageB.modifier) && (messageA.context == messageB.context)) {
return true
}
return false
}
const updateGrouping = () => {
console.log("updating grouping")
const refs = Array.from(messages.value.keys())
// Get the last three messages.
const first = messages.value.get(refs[refs.length - 1])
const second = messages.value.get(refs[refs.length - 2])
const third = messages.value.get(refs[refs.length - 3])
// If messages are simmilar, update the classes.
if ((first) && (second)) {
if (isSimmilar(first, second)) {
first.isChild = "last" // i.e. last in group.
second.isChild = "first"
if (third) {
if ((third.isChild == "first") || (third.isChild == "middle")) {
second.isChild = "middle"
}
}
}
}
}
export default function useMessages() {
// Scroll controller.
const { setScroll, updateScrollRef, adjustScroll } = useScroll("messenger");
// Main function for updating the message view.
const updateMessageView = (message: RenderMessage | Annotation) => {
// Step 1: See if user is scrolled down.
updateScrollRef()
// Step 2: Add the new content to the view.
if ("content" in message) {
addMessage(message)
} else {
updateMessage(message)
}
// Step 3: Pop off oldest message (if > 200).
if (messages.value.size >= 200) {
const oldest = Array.from(messages.value.keys()).shift();
messages.value.delete(oldest);
}
// Step 4: Update grouping.
updateGrouping()
// Setp 5: Scroll the view (if scrolled down).
setTimeout(adjustScroll, 20);
// Step 6: Save the view data.
saveMessages()
}
// Seed message view with message history.
const prepMessageView = (newMessages: RenderMessage[]) => {
console.log("MSGR:Prepping messenger view.")
// Load and render saved messages and immediately scroll to bottom.
loadSavedMessages()
setTimeout(setScroll.bind(false), 10);
// Render new messages, then wait 1s to scroll.
if (newMessages.length) {
console.log("MSGR:Adding new messages")
newMessages.forEach(message => {
message.newMessage = true;
addMessage(message)
})
setTimeout(setScroll.bind(true), 1000);
}
}
return {
messages,
prepMessageView,
updateMessageView
}
}

19
src/modules/mitt.ts Normal file
View file

@ -0,0 +1,19 @@
import { inject } from "vue";
import { Emitter } from "mitt";
// Frontend emitter
type Mitt = Emitter;
let emitter: Mitt;
export default function useMitt() {
const emitterInject: Mitt | undefined = inject("mitt");
if (emitterInject) {
emitter = emitterInject;
}
return {
emitter
}
}

50
src/modules/scroll.ts Normal file
View file

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

72
src/modules/websockets.ts Normal file
View file

@ -0,0 +1,72 @@
"use strict";
import WebSocket from 'ws';
import { ipcMain } from "electron";
let socket: WebSocket;
// Run every time we want to connect to backend.
export default function useWebSockets(receiveCallback: (s: string) => any, openCallback: () => any) {
// Returns bool (sucess or fail).
const sendMessage = (data: any) => {
console.log("WS:Sending message: ", data)
if (socket.readyState !== 1) {
return false
}
else {
socket.send(JSON.stringify(data))
return true
}
}
const onOpen = (event: WebSocket.OpenEvent) => {
console.log("WS:Connected to WS Server!");
openCallback()
}
const onServerMessage = (event: WebSocket.MessageEvent) => {
console.log("WS:Message received: ", event.data);
receiveCallback(event.data.toString())
}
const onClose = (event: WebSocket.CloseEvent) => {
console.log("WS:Socket closed normally.")
}
// Reconnect automatically on error.
const onError = (event: WebSocket.ErrorEvent) => {
console.log("WS:WebSocket error: ", event.message);
console.log("Attempting reconnect in 1s.")
setTimeout(createSocket, 1000)
}
const createSocket = () => {
socket = new WebSocket(`ws://127.0.0.1:8760`)
// Add listeners.
socket.addEventListener("open", onOpen)
socket.addEventListener("message", onServerMessage)
socket.addEventListener("close", onClose)
socket.addEventListener("error", onError)
console.log("WS:New socket created.")
}
return {
createSocket,
sendMessage
}
}

38
src/preload.ts Normal file
View file

@ -0,0 +1,38 @@
// All of the Node.js APIs are available in the preload process.
// It has the same sandbox as a Chrome extension.
import { ipcRenderer } from "electron";
declare global {
interface Window {
ipcRenderer: typeof ipcRenderer;
}
}
window.ipcRenderer = ipcRenderer;
process.once("loaded", () => {
window.addEventListener("message", event => {
// do something with custom event
const message = event.data;
if (message.endpoint === "update-menu-bar") {
ipcRenderer.send("update-menu-bar", message.content);
}
if (message.endpoint === "update-recorder") {
ipcRenderer.send("update-recorder", message.content);
}
if (message.endpoint === "client-message") {
ipcRenderer.send("client-message", message.content);
}
if (message.endpoint === "logout") {
ipcRenderer.send("logout", message.content);
}
});
});

View file

@ -1,12 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 845 B

View file

@ -1,7 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 553 B

View file

@ -1,44 +0,0 @@
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

@ -1,48 +0,0 @@
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,33 +0,0 @@
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

@ -1,195 +0,0 @@
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

@ -1,24 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -1,84 +0,0 @@
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,23 +0,0 @@
export const isEmail = (value) => {
console.log(value);
if (!/(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/.test(value)) {
return "Not a valid email."
}
}
export const isPassword = (value) => {
if (!/^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]{6,16}$/.test(value)) {
return "Password must have symbol, number, uppercase and be 6-16 length.";
}
}
/**
* Name is optional and therefore doesn't return error if no value.
*/
export const isName = (value) => {
if (value) {
if (!/^([a-zA-Z ]){5,30}$/.test(value)) {
return "Name must be at least 5 long.";
}
}
}

View file

@ -1,86 +0,0 @@
// Basic debounce implentation
const debounce = (fn, delay) => {
let timeoutId;
return (...args) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn(...args);
}, delay);
}
}
/**
* Dynamic form component with auto submission and validation.
*
* props
* .fields => List of field objects
* .channel => e.g. "/login"
* .modifier => Form description
*/
const SmartForm =
{
props: ["fields", "channel", "modifier"],
setup(props)
{
// Error log below form
const log = Vue.ref(props.modifier);
/**
* Valid and submit form data to channel.
*/
const submit = async () => {
// Validate each field
for (let field of props.fields) {
const error = field.valid(field.value);
if (error) return error;
}
// Reduce fields into the form
const form = props.fields.reduce((v, n) => {
v[n.name] = n.value;
return v;
}, {});
console.log("Submitting =>", form);
return await window.mainApi.invoke(props.channel, form);
}
/**
* Wrapper for submit that is debounced and updates UI elements
*/
const preSub = debounce(async () => {
const error = await submit();
log.value = error ? error : props.modifier
}, 2000);
return { preSub, log };
},
template: `
<form id="smart-form" class="smart-form" v-on:input="preSub">
<div v-for="field in fields" style="display:flex">
<input type="text" class="text-field"
v-model="field.value"
:name="field.name"
:placeholder="field.placeholder"
/>
</div>
<div class="smart-form-logs">{{ log }}</div>
</form>`
}
export default SmartForm;

View file

@ -1,23 +0,0 @@
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,55 +0,0 @@
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,44 +0,0 @@
import SmartForm from "./form.js";
import { isEmail, isPassword, isName } from "./control/valid.js";
const Login =
{
components: {
SmartForm
},
setup()
{
return { isEmail, isPassword, isName };
},
template: `
<div id="login">
<SmartForm
:fields="[{
name: 'email',
value: null,
placeholder: 'Email',
valid: isEmail
},
{
name: 'password',
value: null,
placeholder: 'Password',
valid: isPassword
},
{
name: 'name',
value: null,
placeholder: 'Name',
valid: isName
}]"
:channel="'auth'"
:modifier="'Login or enter name to register.'"
/>
</div>`
}
export default Login;

View file

@ -1,62 +0,0 @@
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

@ -1,86 +0,0 @@
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,40 +0,0 @@
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,50 +0,0 @@
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,35 +0,0 @@
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,17 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="./main.css">
</head>
<body>
<div id="app"></div>
<!-- [email protected] -->
<script src="./vendor/vue.js"></script>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

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

View file

@ -1,578 +0,0 @@
@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 */
/* Does not work on Linux */
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;
/* Needed for Linux even with higher Z index */
-webkit-app-region: no-drag;
}
.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);
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;
/* Needed for Linux */
-webkit-app-region: no-drag;
}
.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-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,53 +0,0 @@
const { contextBridge, ipcRenderer } = require("electron");
/** The main <-> render interface */
const validFromMainChannels = [
"playback",
"account",
"message",
"record",
"ws"
];
const validToMain = [
"messenger",
"message",
"logout",
"encode",
"auth",
"nav"
]
ipcRenderer.setMaxListeners(250);
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
// TODO: Implement argument filtering for added security
contextBridge.exposeInMainWorld(
"mainApi", {
invoke: async (channel, ...args) => {
if (validToMain.includes(channel)) {
try {
const res = await ipcRenderer.invoke(channel, ...args);
return res;
} catch (e) {
console.log("IPCRenderer.invoke error");
}
}
},
send: (channel, ...args) => {
if (validToMain.includes(channel)) {
ipcRenderer.send(channel, ...args);
}
},
on: (channel, func) => {
if (validFromMainChannels.includes(channel)) {
ipcRenderer.on(channel, (event, ...args) => func(...args));
}
},
removeAllListeners: (channel) => {
ipcRenderer.removeAllListeners(channel);
}
}
);

15872
src/render/vendor/vue.js vendored

File diff suppressed because it is too large Load diff

View file

@ -1,107 +0,0 @@
const { Notification, ipcMain } = require("electron");
const store = require("./utils/store");
const { backgroundMitt, ipcEmit } = require("./utils/emitter");
const { connectToPlatform, disconnectFromPlatform } = require("./io");
// const { initAudio, terminateAudio, playback, streamState } = require("./audio");
const util = require('util');
let ui;
let current;
const messageQueue = [];
let processContentId;
/* Possible messages:
* - New UI (set ui)
* - New message (add message to ui.messages)
* - New content (add content to existing message)
* - Update person
* - Notification
*/
backgroundMitt.on("message", (message) => messageQueue.push(message));
function handleMessageQueue()
{
// if (!streamState.pb && !streamState.rec)
// {
const update = messageQueue.shift();
console.log(update);
if (update)
{
if (update.person)
{
ui = update;
current = update.messages.at(-1);
}
else if (update.name)
{
ui.person = update;
}
else if (update.modifier)
{
ui.messages.push(update);
current = update;
}
else if (update.category)
{
current.content.push(update);
}
else if (update.context)
{
current.context = update.context;
}
else if (update.text)
{
current.content.at(-1).text = update.text;
}
else
{
const content = current.content.at(-1);
new Notification({
title: current.context,
body: content.text
}).show();
/* Ideally, we want a more native solution than this */
// if (current.modifier == "ai" && content.category == "audio")
// {
// playback(content.blob, current.id);
// }
return;
}
ipcEmit("message", update);
}
// }
}
function launchSession(account)
{
connectToPlatform(account);
// initAudio();
processContentId = setInterval(handleMessageQueue, 100);
}
function endSession()
{
clearInterval(processContentId);
// terminateAudio();
disconnectFromPlatform();
ui = null;
}
ipcMain.on("messenger", () => {
if (ui) {
ipcEmit("message", ui);
}
});
exports.launchSession = launchSession;
exports.endSession = endSession;

13
src/shims-vue.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
declare module "*.vue" {
import { defineComponent } from "vue";
const component: ReturnType<typeof defineComponent>;
export default component;
}
declare module "anime-js" {
namespace anime {
import * as anime from "animejs/lib/anime.es.js";
function anime(): void;
export = anime;
}
}

View file

@ -1,43 +0,0 @@
const fs = require("fs");
const path = require("path");
const { Tray } = require("electron");
const { backgroundMitt } = require("./utils/emitter");
let tray;
const TRAY_PATH = path.join(__dirname, "assets/tray");
/* Current state of recording, playback, and ws connection */
const state = {
recording: false,
playback: false,
disconnect: 0 // whether we're experiencing connection issues
}
/**
* Updates the tray icon given a new state of an attribute.
* @param {"recording" | "playback" | "disconnect"} attribute
* @param {boolean} newState
*/
function updateTray(attribute, newState)
{
state[attribute] = newState;
// convert boolean to number
const bi = (b) => b ? 1 : 0;
// Make sure we have the icon we're looking for.
tray.setImage(path.join(
TRAY_PATH,
`${bi(state.recording)}${bi(state.playback)}${bi(state.disconnect)}` +
".png"
));
}
function initTray()
{
tray = new Tray(path.join(TRAY_PATH, "000.png"));
}
exports.initTray = initTray;
exports.updateTray = updateTray;

74
src/types.ts Normal file
View file

@ -0,0 +1,74 @@
export interface RenderMessage {
content: {
text: boolean | string;
audio: boolean | string;
};
context: string;
modifier: string;
time: number;
uid: string;
isChild: string;
seen: boolean;
newMessage: boolean;
}
export interface ClientMessage {
text: string;
audio: boolean | string;
uid: string;
}
export interface ClientRequest {
intent: string;
params: object;
epic: string | boolean;
confidence: number;
}
export interface SessionState {
key: string | boolean;
newMessages: RenderMessage[];
}
export interface WindowState {
width: number;
height: number;
x: number | null;
y: number | null;
}
export interface AuthRequest {
key: boolean | string;
usr: boolean | string;
pwd: boolean | string;
}
export interface Profile {
crimataId: string;
alias: string;
initials: string;
}
export interface AuthProtocol {
key: boolean | string;
profile: boolean | Profile;
}
export interface LogoutRequest {
logout: boolean;
}
export interface Annotation {
text: string;
context: string;
uid: string;
}
export interface StandardMessage {
content: {
text: boolean | string;
audio: boolean | string;
};
context: string;
modifier: string;
}

View file

@ -1,11 +0,0 @@
const EventEmitter = require("events");
const backgroundMitt = new EventEmitter();
const ipcEmit = (channel, ...args) =>
{
backgroundMitt.emit("ipc-renderer", channel, ...args);
};
module.exports.backgroundMitt = backgroundMitt;
module.exports.ipcEmit = ipcEmit;

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