Compare commits

..
Author SHA1 Message Date
Andrew Gundersen
d3f99d6133 html bubbles, more simplifications, new session protocol 2022-01-08 16:53:45 -06:00
Andrew Gundersen
0c6a58ea24 html rendering, bug fixes 2021-12-01 19:33:25 -06:00
Andrew Gundersen
d5fd8b39e7 window null bug fix 2021-11-23 13:39:40 -06:00
Andrew Gundersen
7de0555856 recording timer, bux fixes, simplified emitter 2021-11-22 15:59:33 -06:00
Andrew Gundersen
ccb07d594b bux fixes, profile initials 2021-11-18 09:42:05 -06:00
Andrew Gundersen
41528e7e27 production refactor 2021-10-16 09:39:10 -05:00
Andrew Gundersen
92fb472e3c [WIP] -> yarn 3 2021-08-26 13:15:28 -05:00
Andrew Gundersen
f9dcb00e2d [WIP] refactor, upgrading yarn 2021-08-26 12:31:18 -05:00
Andrew Gundersen
a4d6a883b0 WIP:playback queue and tray icon states 2021-08-24 15:04:21 -05:00
Andrew Gundersen
c52ea2bcd2 successfully imports nodeaudio, add yarnrc.yml for registry config 2021-08-19 15:11:14 -05:00
Andrew Gundersen
622c705c35 removing node-abi 2021-08-18 10:58:42 -05:00
Andrew Gundersen
e9972bd70a iohook settings 2021-08-18 10:19:52 -05:00
Enrique Hernandez
6478b1250b Merge branch 'default_tray_icon' into 'dev'
Default tray icon

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

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

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

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

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

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

View file

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

9
.gitignore vendored
View file

@ -1,5 +1,5 @@
.DS_Store
node_modules
/node_modules
/dist
/crate/target/*
/.log
@ -28,10 +28,3 @@ pnpm-debug.log*
#Electron-builder output
/dist_electron
#Electron-builder config
electron-builder.yml
#Window and session states
session.json
window.json

2
.npmrc Normal file
View file

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

View file

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

View file

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

View file

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

2213
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

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

44
src/account.js Normal file
View file

@ -0,0 +1,44 @@
const { ipcMain } = require("electron");
const store = require("./utils/store");
const { postLogin } = require("./api");
const { ipcEmit } = require("./utils/emitter");
const { launchSession, endSession } = require("./session");
// store.set("account", "smith12@gmail.com");
store.set("account", "adgundersen@gmail.com");
async function login(_e, email, password)
{
const res = await postLogin(email, password);
if (res)
{
const account = res.crimataId;
store.set("account", account);
ipcEmit("set-account", account);
launchSession(account);
return true;
}
else
{
return false;
}
};
function logout()
{
store.delete("account");
ipcEmit("set-account", false);
endSession();
};
function initAccount()
{
ipcMain.handle("login", login);
ipcMain.on("logout", logout);
const account = store.get("account");
if (account) launchSession(account);
}
exports.initAccount = initAccount;

View file

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

19
src/api.js Normal file
View file

@ -0,0 +1,19 @@
const axios = require('axios').default;
const config = require("./config");
const baseUrl = config.BUSINESS_URL + config.BUSINESS_PREFIX;
async function postLogin(email, password)
{
try {
const res = await axios.post(baseUrl + "/account/login", {
email: email, password: password
});
return res.data;
} catch (e) {
return false;
}
}
exports.postLogin = postLogin;

View file

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

BIN
src/assets/icon.icns Normal file

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

137
src/audio.js Normal file
View file

@ -0,0 +1,137 @@
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.prototype.bind(null, 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/L16",
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;
// setWriteId = setTimeout(() => {
// setPlaybackStatus(false);
// }, 500);

View file

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

View file

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

13
src/codec.js Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

80
src/io.js Normal file
View file

@ -0,0 +1,80 @@
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_URL}/${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 !== "logout") {
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);
connection.close(1000, "logout");
}
function setConnectionStatus(status)
{
updateTray("disconnect", status);
ipcEmit("ws-status", status);
}
ipcMain.on("message", (_e, message) => sendMessage(message));
exports.connectToPlatform = connectToPlatform;
exports.sendMessage = sendMessage;
exports.disconnectFromPlatform = disconnectFromPlatform;
/* ------------- Test Messages------------- */
setTimeout(() => {
// sendMessage({
// category: "text/plain",
// text: "show contacts",
// blob: null
// })
sendMessage({
category: "text/plain",
text: "add contact John Smith, john.smith97@gmail.com",
blob: null
})
}, 2000);

View file

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

View file

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

View file

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

29
src/main.js Normal file
View file

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

View file

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

52
src/preview.html Normal file
View file

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0;
padding: 0;
font-family: "SF Pro Text";
font-size: 14px;
}
#titlebar {
position: fixed;
width: 100vw;
height: 54px;
opacity: 0;
-webkit-app-region: drag;
border: none;
outline: none;
z-index: 1;
}
.building {
width: 250px;
height: 150px;
}
.email {
padding: 15px;
}
.email-subject {
font-size: 16px;
margin-top: 10px;
}
</style>
</head>
<body>
<button id="titlebar"></button>
<div id="content"></div>
<script>
window.mainApi.on("preview-init", (content) => {
document.getElementById("content").innerHTML = content;
});
</script>
</body>
</html>

View file

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

View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24.87" height="23.001" viewBox="0 0 24.87 23.001">
<g id="Group_541" data-name="Group 541" 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 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 0)" 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>
</g>
</svg>

After

Width:  |  Height:  |  Size: 973 B

View file

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

After

Width:  |  Height:  |  Size: 553 B

View file

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

View file

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

View file

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

View file

@ -0,0 +1,38 @@
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'">
<div
v-show="avatar"
class="context-avatar"
:class="{ audio: playback }"
>
<img :src="'data:image/png;base64,' + avatar" />
</div>
{{ context }}
</span> `
}
export default Context;

View file

@ -0,0 +1,198 @@
const saveLocation = "input_item_position";
const defaultPosition = { x: 15, y: window.innerHeight - 300 };
//---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, margin) {
let element;
/* 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;
// Keep track of window size;
let winW = window.innerWidth;
let winH = window.innerHeight;
//---Reposition Anime-----------------------------------------------
// Move element to target smoothly.
const repositionAnime = (xChange, yChange) => {
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) => {
e.preventDefault();
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) => {
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;
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) => {
// Update window dimensions.
winW = window.innerWidth;
winH = window.innerHeight;
elementX.value = calcPosition(elementX.value, elementLength, percentX, xShort, winW);
elementY.value = calcPosition(elementY.value, elementLength, percentY, yShort, winH);
savePosition();
}
const savePosition = () => {
window.localStorage.setItem(saveLocation, JSON.stringify({
x: elementX.value,
y: elementY.value
}));
}
//---------------------------------------------------------------
Vue.onMounted(() => {
element = document.getElementById(elementId);
elementLength = element.offsetWidth;
// Initialize the positional references.
percentX = elementX.value / window.innerWidth;
percentY = elementY.value / window.innerHeight;
xShort = calcSideProximity(elementX.value, elementLength, window.innerWidth);
yShort = calcSideProximity(elementX.value, elementLength, window.innerHeight);
// Then, we can listen for window resize (and mousedown).
element.addEventListener("mousedown", onMouseDown);
window.addEventListener('resize', onWindowResize)
});
// remove event listeners on component dismount.
Vue.onUnmounted(() => {
element.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('resize', onWindowResize)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('mousemove', onMouseMove);
});
// Try loading initPosition, otherwise set default values
let initPosition;
const rawData = window.localStorage.getItem("saveLocation")
rawData ? initPosition = JSON.parse(rawData) : initPosition = defaultPosition;
elementX.value = initPosition.x;
elementY.value = initPosition.y;
return { elementX, elementY };
}
export default draggify;

View file

@ -0,0 +1,24 @@
let el;
const initScroll = (elementId) => {
el = document.getElementById(elementId);
el.addEventListener('adjust-scroll', adjustScroll);
window.addEventListener('resize', adjustScroll);
}
const isBottom = () => {
if (el) {
return el.scrollHeight - el.clientHeight <= el.scrollTop + 1;
}
}
const adjustScroll = () => {
if (el) {
el.scrollTo({
top: el.scrollHeight - el.clientHeight,
behavior: 'smooth'
});
}
}
export default initScroll;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,58 @@
const Login =
{
setup()
{
const usr = Vue.ref("");
const pwd = Vue.ref("");
const submitForm = async () => {
const success = await window.mainApi.invoke("login", usr.value, pwd.value);
if (!success) shake("login-form");
}
return { usr, pwd, submitForm };
},
template: `
<form id="login" @submit.prevent="submitForm">
<!-- username and password forms -->
<div id="login-form">
<!-- username -->
<div class="login-input-box">
<input class="login-input" type="text" v-model="usr" />
<div class="login-input-modifier">Email</div>
</div>
<!-- password -->
<div class="login-input-box">
<input class="login-input" type="password" v-model="pwd" />
<div class="login-input-modifier">Password</div>
</div>
</div>
<!-- submit button; position: fixed -->
<button
class="login-submit-button button"
type="submit"
>
Login
</button>
</form> `
}
/** Shake a div to indicate incorrect credentials */
const shake = (elementId) =>
{
const el = document.getElementById(elementId);
if (el)
el.classList.remove("shake");
setTimeout(() => {
if (el) el.classList.add("shake");
}, 250);
}
export default Login;

View file

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

View file

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

View file

@ -0,0 +1,102 @@
import initScroll 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([{
modifier: "ai",
content: [{
category: "text/html",
text: `<div class="mail">
<h4>About HTML Parser</h4>
<p>Return the text of the most recently opened start tag. This should not normally be needed for structured processing, but may be useful in dealing with HTML as deployed or for re-generating input with minimal changes (whitespace between attributes can be preserved, etc.).</p>
</div>`,
blob: null
}],
context: "Email from David Spade",
avatar: null,
id: 0
}]);
const onDrop = async (e) => {
const file = e.dataTransfer.items[0].getAsFile();
const buffer = await file.arrayBuffer();
const b64String = await window.mainApi.invoke("encode", buffer);
window.mainApi.send("message", {
category: file.type,
text: file.name,
blob: b64String
});
}
Vue.onMounted(() => {
window.mainApi.on("message", (message) => {
if (message.person)
{
person.value = message.person;
messages.value = message.messages;
}
else if (message.name)
{
person.value = message;
}
else if (message.content)
{
messages.value.push(message);
}
else
{
messages.value[messages.value.length - 1].content.push(message);
}
});
initScroll("messenger");
window.mainApi.send("messenger");
});
Vue.onUnmounted(() => {
messages.value = [];
});
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"
:modifier="message.modifier"
:content="message.content"
:context="message.context"
:avatar="message.avatar"
:id="message.id"
/>
</div> `
}
export default Messenger;

View file

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

View file

@ -0,0 +1,28 @@
const Settings =
{
setup() {
const active = Vue.ref(false);
window.addEventListener("keydown", (e) => {
console.log("toggle settings");
})
const logout = async () => {
await window.mainApi.send("logout");
}
return { active, logout };
},
template: `
<div v-if="active" id="settings" >
<button class="settings-logout-button button" @click="logout">
Logout
</button>
</div>`
}
export default Settings;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

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

@ -0,0 +1,571 @@
html, body {
margin: 0;
padding: 0;
}
/* The first word of the class is the component that it modifys. */
#splash {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#app {
font-family: "SF Pro Text";
-webkit-font-smoothing: antialiased;
height: 100vh;
width: 100vw;
border-radius: 15px;
}
#header-titlebar {
position: fixed;
width: 100vw;
height: 54px;
opacity: 0.75;
background-color: #EBEBEB;
-webkit-app-region: drag;
border: none;
outline: none;
z-index: 1;
}
.contacts {
margin: 0;
padding: 0;
border-radius: inherit;
background-color: #DBDBDB;
}
.contacts > li {
padding: 12px;
display: flex;
}
.contacts img {
border-radius: 50%;
}
.contacts .info {
margin-left: 10px;
margin-right: 10px;
}
.contacts .name {
}
.contacts .email {
font-family: SF Compact Display;
font-weight: bold;
font-size: 12px;
color: #898989;
}
.mail {
padding: 12px;
margin: 0px;
background-color: #D6F2FF;
border-radius: inherit;
}
.mail h4 {
margin-top: 0px;
}
.mail p {
margin-bottom: 0px;
}
#header-menu {
position: fixed;
margin-left: 20px;
margin-top: 20px;
z-index: 2;
}
.header-menu-button {
min-width: 14px;
min-height: 14px;
border-radius: 7px;
}
.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;
}
#login-form {
font-family: "SF Compact Display";
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-bottom: 20px;
}
.login-input-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
.login-input {
background-color: #B9B9B9;
border: none;
outline: none;
padding: 16px;
text-decoration: none;
margin: 4px 2px;
cursor: pointer;
border-radius: 10px;
}
.login-input-modifier {
min-width: 181px;
font-size: 12px;
font-weight: bold;
text-align: left;
margin-left: 15px;
}
.login-submit-button {
font-family: "SF Compact Display";
position: fixed;
margin-top: 260px;
background-color: #58C4FD;
color: white;
padding: 10px 30px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
.login-submit-button:active {
background-color: #4296C3;
}
/* generic shaking functionality */
.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);
}
}
#messenger {
width: 100vw;
height: 100vh;
overflow: auto;
}
/* hide native scrollbar */
#messenger::-webkit-scrollbar {
display: none;
}
#ws {
position: fixed;
width: 100vw;
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;
}
.ws-dot::before {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #48E065;
left: -9px;
animation: ws-dot-flashing 1s infinite alternate;
animation-delay: 0s;
}
.ws-dot::after {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #48E065;
left: 9px;
animation: ws-dot-flashing 1s infinite alternate;
animation-delay: 0.5s;
}
@keyframes ws-dot-flashing {
0% {
background-color: #48E065;
}
50%,
100% {
background-color: #9B9B9B;
}
}
#input {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
width: 40px;
height: 40px;
z-index: 3;
/* To prevent window drag when overlapping with titlebar. */
-webkit-app-region: no-drag;
border-radius: 50%;
/* Set opacity here to not affect child. */
background-color: rgba(235, 235, 235, 0.75);
cursor: pointer;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
}
#input-text {
position: absolute;
min-width: 150px;
height: 16px;
border-radius: 18px;
padding: 10px;
outline: none;
border: none;
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: fixed;
width: 100vw;
height: 100vh;
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;
}
@keyframes settings-appear {
from {
background-color: rgba(235, 235, 235, 0);
}
to {
background-color: rgba(235, 235, 235, 0.75);
}
}
.settings-icon {
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;
}
.settings-icon:hover {
cursor: pointer;
}
.settings-account {
font-size: 12px;
font-weight: bold;
margin-bottom: 25px;
}
.settings-logout-button {
font-family: "SF Compact Display";
background-color: #B7B7B7;
padding: 10px 30px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
.settings-logout-button:hover {
cursor: pointer;
}
.settings-version {
position: absolute;
left: 50%;
top: 75%;
transform: translate(-50%, -50%);
font-size: 12px;
font-weight: bold;
color: #575757;
}
.message {
width: 100vw;
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;
}
.client-message {
align-items: flex-end;
}
.ai-message {
align-items: flex-start;
}
.bubble {
position: relative;
max-width: 66vw;
font-family: "SF Pro Text";
font-size: 14px;
border-radius: 18px;
margin-bottom: 4px;
}
.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: inherit;
}
.bubble a {
border-radius: inherit;
font-family: "SF Compact Rounded";
background-color: #D9D9D9;
font-weight: bold;
padding: 12px;
color: #727272;
margin: 0px;
}
.context {
position: relative;
min-height: 14px;
display: flex;
align-items: center;
font-family: "SF Compact Display";
font-size: 12px;
font-weight: bold;
margin-top: 5px;
}
.ai-context {
margin-left: 15px;
}
.client-context {
margin-right: 15px;
}
.context-avatar {
display: flex;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
background-color: white;
border-radius: 15px;
margin-right: 5px;
}
.context-avatar img {
border-radius: 50%;
}
/* ---------- shared between components ---------- */
.button {
border: none;
outline: none;
text-decoration: none;
}
/*.button:hover {
cursor: pointer;
}*/
.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);
}
}

View file

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

53
src/render/preload.js Normal file
View file

@ -0,0 +1,53 @@
const { contextBridge, ipcRenderer } = require("electron");
/** The main <-> render interface */
const validFromMainChannels = [
"set-account",
"message",
"ws-status",
"record",
"playback"
];
const validToMain = [
"messenger",
"message",
"login",
"logout",
"encode",
"decode"
]
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);
}
}
);

View file

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

15872
src/render/vendor/vue.js vendored Normal file

File diff suppressed because it is too large Load diff

97
src/session.js Normal file
View file

@ -0,0 +1,97 @@
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");
let ui; /* wait to receive ui from the platform */
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
*/
backgroundMitt.on("message", (message) => messageQueue.push(message));
function handleMessageQueue()
{
if (!streamState.pb && !streamState.rec)
{
const update = messageQueue.shift();
if (update)
{
console.log("Handling UI Update:", update);
if (update.person)
{
ui = update;
}
else if (update.name)
{
ui.person = update;
}
else if (update.content)
{
ui.messages.push(update);
}
else
{
ui.messages[ui.messages.length - 1].content.push(update);
}
ipcEmit("message", update);
// notify();
}
}
}
function notify()
{
const message = ui.messages[ui.messages.length - 1];
const content = message.content[message.content.length -1];
new Notification(
{
title: message.context,
body: content.text
}
).show();
if (content.category == "audio/L16") playback(content.blob, message.id);
}
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;
// ui.messages[ui.messages.findIndex(m => m.id === message.id)].content.push(message);

View file

@ -1,101 +0,0 @@
// import useAudio from "@/audio";
import { ipcEmit } from "@/composables/useEmitter";
import useWebsockets from "./composables/useWebsockets";
import {config} from "@/config";
/* data structure of messages that's tied to the UI */
const uiState: any | null = null;
/* start and stop audio functionality */
// const { initAudio, closeAudio } = useAudio();
const isInitMessage = (message: any): boolean => {
return true;
};
const deauthenticate = (): void => {
console.log('deauthenticating')
};
const isAddMessage = (message: any): boolean => {
return true;
};
/**
* Controls for interfacing with the platform.
* Takes an onMessage callback which we define below.
*/
const onMessageCallback = (payload: string) => {
const message = JSON.parse(payload);
console.log(typeof message);
/* if the platform fails to authenticate, we must back down */
if (message === "CLOSE_AUTH_FAIL") {
deauthenticate();
return;
}
ipcEmit("add-message", message)
return
/* on init, platform sends state, used to init canvas */
if (isInitMessage(message)) {
ipcEmit("init-messages", message)
}
else if (isAddMessage(message)) {
ipcEmit("add-messages", message)
}
else {
ipcEmit("update-messages", message)
}
}
const onConnectionStatusCallback = (alive: boolean) => {
// console.log('[Session]: Connection Alive: ', alive);
// ipcEmit('connection-state', alive);
}
const { connect, send, close } = useWebsockets(
onMessageCallback,
onConnectionStatusCallback
);
/* send a message to the platform */
export function sendMessage<Message>(message: Message): void {
/* socket send */
send(message);
}
/* launch a new session (the main process for authenticated users) */
export function launchSession(platformKey: string) {
/* connect to the platform */
connect(config.PLATFORM_URL, platformKey);
/* initialize the audio streams */
// initAudio();
}
export function endSession() {
// closeAudio();
close();
}

View file

@ -1,31 +0,0 @@
const Store = require('electron-store');
const schema = {
token: {
type: 'string',
},
profile: {}
};
const store = new Store({
schema,
encryptionKey: "super user test"
});
export const getToken = (): string | undefined => (store.get("token"));
export const clearToken = (): void => (store.delete("token"));
export const setToken = (token: string): void => (store.set('token', token));
export const setProfile = (profile: Profile): Profile => (store.set('profile', profile));
export const getProfile = (): Profile => (store.get('profile'));
export const clearProfile = (): void => (store.delete('profile'));
export const clearStore = (): void => {
clearToken();
clearProfile();
}

44
src/tray.js Normal file
View file

@ -0,0 +1,44 @@
const fs = require("fs");
const path = require("path");
const { Tray } = require("electron");
const { backgroundMitt } = require("./utils/emitter");
let tray;
const TRAY_PATH = "./src/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(
TRAY_PATH +
`${bi(state.recording)}${bi(state.playback)}${bi(state.disconnect)}` +
".png"
);
}
function initTray()
{
tray = new Tray(TRAY_PATH + "000" + ".png");
}
exports.initTray = initTray;
exports.updateTray = updateTray;

View file

@ -1,77 +0,0 @@
interface Message {
text: boolean | string;
context: boolean | string;
audio: boolean | string;
type: 1 | 2 | 3;
uid: string;
}
interface ViewMessage extends Message {
child: string;
}
interface WindowState {
width: number;
height: number;
x: number | null;
y: number | null;
}
interface Profile {
crimataId: string;
alias: string;
initials: string;
}
interface LoginPayload {
email: string;
password: string;
}
interface AuthState {
profile: Profile | null;
token: string | null;
}
interface AccountCredentials {
email: string;
password: string;
}
/*
* Electron Ipc
*/
interface IpcHandlerCallback<I, O> {
(payload: I | null): Promise<Error | O>;
}
interface IpcListenerCallback<T> {
(payload: T | null): void;
}
interface IIpcHandler<I, O> {
handle(): void;
remove(): void;
readonly _handlerCallback: IpcHandlerCallback<I, O>;
}
interface IIpcListener<I> {
listen(): void;
remove(): void;
readonly _listenerCallback: IpcListenerCallback<I>;
}
interface IPCHandlers {
[handler: string]: IIpcHandler<any, any>;
}
interface IPCListeners {
[listener: string]: IIpcListener<any> | null;
}
interface IpcRendererEvent<T> {
channel: string;
payload: T | null;
}

11
src/utils/emitter.js Normal file
View file

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