Compare commits

..
121 changed files with 17609 additions and 19226 deletions

38
.eslintrc.js Normal file
View file

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

18
.gitignore vendored
View file

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

63
.gitlab-ci.yml Normal file
View file

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

2
.npmrc
View file

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

View file

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

5
babel.config.js Normal file
View file

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

View file

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

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

6
jest.config.js Normal file
View file

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

6
jsconfig.json Normal file
View file

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

View file

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

1059
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,104 @@
{
"name": "Crimata",
"productName": "crimata-messenger",
"version": "1.0.0",
"version": "0.9.9",
"private": true,
"description": "Cross-platform messenger app (Electron, Vue3)",
"description": "Cross-platform messenger application built with electron, vue3, and TS.",
"author": {
"name": "Andrew Gundersen"
"name": "Enrique Hernandez"
},
"main": "src/main.js",
"scripts": {
"start": "electron ."
"build": "vue-cli-service electron:build",
"dev": "vue-cli-service electron:serve",
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps"
},
"main": "init.js",
"dependencies": {
"@crimata/nodeaudio": "0.0.0",
"@google-cloud/speech": "^4.2.0",
"@types/animejs": "^3.1.2",
"@types/bindings": "^1.3.0",
"@types/dom-mediacapture-record": "^1.0.7",
"@types/node": "^14.14.25",
"@types/uuid": "^8.3.0",
"@types/ws": "^7.2.7",
"animejs": "^3.2.0",
"axios": "^0.21.1",
"base64-arraybuffer": "^1.0.1",
"core-js": "^3.6.5",
"electron-is-dev": "^2.0.0",
"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": {
"electron-notarize": "^1.1.1",
"electron-osx-sign": "^0.6.0"
"@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"
}
}

17
public/index.html Normal file
View file

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

View file

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

95
src/account.ts Normal file
View file

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

View file

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

31
src/api/account.ts Normal file
View file

@ -0,0 +1,31 @@
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'));

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

View file

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

187
src/audio.ts Normal file
View file

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

13
src/auth.ts Normal file
View file

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

View file

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

View file

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

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

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

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

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

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

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

17
src/config.ts Normal file
View file

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

52
src/init.ts Normal file
View file

@ -0,0 +1,52 @@
/**
* 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();
});
}

View file

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

28
src/ipc/handlers.ts Normal file
View file

@ -0,0 +1,28 @@
"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
});

33
src/ipc/index.ts Normal file
View file

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

24
src/ipc/listeners.ts Normal file
View file

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

View file

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

39
src/main.ts Normal file
View file

@ -0,0 +1,39 @@
/**
* 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();
}
}

76
src/render/App.vue Normal file
View file

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

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

Before

Width:  |  Height:  |  Size: 845 B

View file

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

Before

Width:  |  Height:  |  Size: 553 B

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

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

View file

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

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

View file

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

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

View file

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

80
src/render/ipc.ts Normal file
View file

@ -0,0 +1,80 @@
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 = {};
};

32
src/render/listeners.ts Normal file
View file

@ -0,0 +1,32 @@
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
});

View file

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

21
src/render/main.ts Normal file
View file

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

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