Compare commits
38
.eslintrc.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
38
.gitignore
vendored
|
|
@ -1,5 +1,37 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/node_modules
|
node_modules
|
||||||
/out
|
/dist
|
||||||
|
/crate/target/*
|
||||||
|
/.log
|
||||||
|
|
||||||
*.sublime-workspace
|
crash.log
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
#Electron-builder output
|
||||||
|
/dist_electron
|
||||||
|
|
||||||
|
#Electron-builder config
|
||||||
|
electron-builder.yml
|
||||||
|
|
||||||
|
#Window and session states
|
||||||
|
session.json
|
||||||
|
window.json
|
||||||
|
|
|
||||||
63
.gitlab-ci.yml
Normal file
|
|
@ -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
|
|
@ -1,2 +0,0 @@
|
||||||
@crimata:registry=https://gitlab.com/api/v4/projects/28849281/packages/npm/
|
|
||||||
//gitlab.com/api/v4/projects/28849281/packages/npm
|
|
||||||
29
README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# crimata-electron
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
```
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and hot-reloads for development
|
||||||
|
```
|
||||||
|
yarn electron:serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and minifies for production
|
||||||
|
```
|
||||||
|
yarn electron:build
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- ### Run your unit tests
|
||||||
|
```
|
||||||
|
yarn test:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lints and fixes files
|
||||||
|
```
|
||||||
|
yarn lint
|
||||||
|
``` -->
|
||||||
|
|
||||||
|
### Customize configuration
|
||||||
|
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||||
5
babel.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
'@vue/cli-plugin-babel/preset'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"folders":
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"path": "."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
Before Width: | Height: | Size: 55 KiB |
6
jest.config.js
Normal 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
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"typeAcquisition": {
|
||||||
|
|
||||||
|
"enable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
13151
package-lock.json
generated
136
package.json
|
|
@ -1,54 +1,104 @@
|
||||||
{
|
{
|
||||||
"name": "crimata",
|
"name": "Crimata",
|
||||||
"version": "1.0.0",
|
"version": "0.9.9",
|
||||||
"description": "Cross-platform messenger app (Electron, Vue3)",
|
"private": true,
|
||||||
"main": "src/main.js",
|
"description": "Cross-platform messenger application built with electron, vue3, and TS.",
|
||||||
|
"author": {
|
||||||
|
"name": "Enrique Hernandez"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-forge start",
|
"build": "vue-cli-service electron:build",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"dev": "vue-cli-service electron:serve",
|
||||||
"package": "electron-forge package",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"make": "electron-forge make"
|
"postuninstall": "electron-builder install-app-deps"
|
||||||
},
|
|
||||||
"author": "Andrew Gundersen",
|
|
||||||
"license": "ISC",
|
|
||||||
"devDependencies": {
|
|
||||||
"@electron-forge/cli": "^6.0.0-beta.63",
|
|
||||||
"@electron-forge/maker-deb": "^6.0.0-beta.63",
|
|
||||||
"@electron-forge/maker-rpm": "^6.0.0-beta.63",
|
|
||||||
"@electron-forge/maker-squirrel": "^6.0.0-beta.63",
|
|
||||||
"@electron-forge/maker-zip": "^6.0.0-beta.63",
|
|
||||||
"electron": "^18.2.3"
|
|
||||||
},
|
},
|
||||||
|
"main": "init.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google-cloud/speech": "^4.2.0",
|
||||||
|
"@types/animejs": "^3.1.2",
|
||||||
|
"@types/bindings": "^1.3.0",
|
||||||
|
"@types/dom-mediacapture-record": "^1.0.7",
|
||||||
|
"@types/node": "^14.14.25",
|
||||||
|
"@types/uuid": "^8.3.0",
|
||||||
|
"@types/ws": "^7.2.7",
|
||||||
|
"animejs": "^3.2.0",
|
||||||
"axios": "^0.21.1",
|
"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-store": "^8.0.0",
|
||||||
"ws": "^7.3.1",
|
"electron-updater": "^4.3.8",
|
||||||
"electron-squirrel-startup": "^1.0.0"
|
"mitt": "^2.1.0",
|
||||||
|
"naudiodon": "^2.3.2",
|
||||||
|
"node-record-lpcm16": "^1.0.1",
|
||||||
|
"update-electron-app": "^2.0.1",
|
||||||
|
"uuid": "^8.3.2",
|
||||||
|
"vue": "^3.0.0-0",
|
||||||
|
"vue-router": "^4.0.0-0",
|
||||||
|
"vuex": "^4.0.0-0",
|
||||||
|
"ws": "^7.3.1"
|
||||||
},
|
},
|
||||||
"config": {
|
"devDependencies": {
|
||||||
"forge": {
|
"@types/axios": "^0.14.0",
|
||||||
"packagerConfig": {},
|
"@types/electron-devtools-installer": "^2.2.0",
|
||||||
"makers": [
|
"@types/jest": "^24.0.19",
|
||||||
{
|
"@typescript-eslint/eslint-plugin": "^2.33.0",
|
||||||
"name": "@electron-forge/maker-squirrel",
|
"@typescript-eslint/parser": "^2.33.0",
|
||||||
"config": {}
|
"@vue/cli-plugin-babel": "~4.5.0",
|
||||||
},
|
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||||
{
|
"@vue/cli-plugin-router": "~4.5.0",
|
||||||
"name": "@electron-forge/maker-zip",
|
"@vue/cli-plugin-typescript": "~4.5.0",
|
||||||
"platforms": [
|
"@vue/cli-plugin-unit-jest": "~4.5.0",
|
||||||
"darwin"
|
"@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",
|
||||||
"name": "@electron-forge/maker-deb",
|
"@vue/test-utils": "^2.0.0-0",
|
||||||
"config": {}
|
"@wasm-tool/wasm-pack-plugin": "^1.3.1",
|
||||||
},
|
"electron": "^9.0.0",
|
||||||
{
|
"electron-devtools-installer": "^3.1.0",
|
||||||
"name": "@electron-forge/maker-rpm",
|
"electron-log": "^4.3.4",
|
||||||
"config": {}
|
"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
|
|
@ -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>
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
const { ipcMain } = require("electron");
|
|
||||||
|
|
||||||
const { post } = require("./api");
|
|
||||||
const store = require("./utils/store");
|
|
||||||
const { launchSession, endSession } = require("./session");
|
|
||||||
const { backgroundMitt, ipcEmit } = require("./utils/emitter");
|
|
||||||
|
|
||||||
store.set("account", process.env.ACCOUNT);
|
|
||||||
|
|
||||||
async function auth(_e, creds)
|
|
||||||
{
|
|
||||||
const { error, data } = await post("/auth", creds);
|
|
||||||
|
|
||||||
if (error)
|
|
||||||
{
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.set("account", data);
|
|
||||||
ipcEmit("account", data);
|
|
||||||
launchSession(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
function logout(_e, reason)
|
|
||||||
{
|
|
||||||
store.delete("account");
|
|
||||||
ipcEmit("account", false, reason);
|
|
||||||
endSession();
|
|
||||||
};
|
|
||||||
|
|
||||||
function initAccount()
|
|
||||||
{
|
|
||||||
ipcMain.handle("auth", auth);
|
|
||||||
ipcMain.on("logout", logout);
|
|
||||||
|
|
||||||
const account = store.get("account");
|
|
||||||
if (account) launchSession(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
backgroundMitt.on("logout", (reason) => logout(null, reason));
|
|
||||||
|
|
||||||
|
|
||||||
exports.initAccount = initAccount;
|
|
||||||
95
src/account.ts
Normal 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
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
38
src/api.js
|
|
@ -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
|
|
@ -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'));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
134
src/audio.js
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
13
src/codec.js
|
|
@ -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;
|
|
||||||
31
src/composables/autoUpdate.ts
Normal 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()
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
16
src/composables/useEmitter.ts
Normal 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
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
51
src/composables/useHttp.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/composables/useIpcMain.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
36
src/composables/useMessageCanvas.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
src/composables/useSaveToJSON.ts
Normal 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.")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
89
src/composables/useWebsockets.ts
Normal 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
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
const { app } = require("electron");
|
|
||||||
|
|
||||||
const DOMAIN = "crimata.com";
|
|
||||||
|
|
||||||
/* If PROD env variable exists */
|
|
||||||
const prod = process.env.PROD;
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
PLATFORM: prod ? `https://app.${DOMAIN}` : `http://localhost:5000`,
|
|
||||||
API: prod ? `https://${DOMAIN}/api` : `http://localhost:5001`
|
|
||||||
}
|
|
||||||
17
src/config.ts
Normal 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
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
69
src/io.js
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
});
|
||||||
28
src/main.js
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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");
|
|
||||||
|
|
@ -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
|
|
||||||
331
src/render/components/bubble.vue
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
export const isEmail = (value) => {
|
|
||||||
console.log(value);
|
|
||||||
if (!/(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/.test(value)) {
|
|
||||||
return "Not a valid email."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isPassword = (value) => {
|
|
||||||
if (!/^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]{6,16}$/.test(value)) {
|
|
||||||
return "Password must have symbol, number, uppercase and be 6-16 length.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name is optional and therefore doesn't return error if no value.
|
|
||||||
*/
|
|
||||||
export const isName = (value) => {
|
|
||||||
if (value) {
|
|
||||||
if (!/^([a-zA-Z ]){5,30}$/.test(value)) {
|
|
||||||
return "Name must be at least 5 long.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
src/render/components/controllers/bubble.control.ts
Normal file
102
src/render/components/controllers/helpers.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
77
src/render/components/controllers/inputItem.control.audio.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
134
src/render/components/controllers/inputItem.control.text.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
38
src/render/components/controllers/messenger.control.ts
Normal 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
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
|
|
||||||
// Basic debounce implentation
|
|
||||||
const debounce = (fn, delay) => {
|
|
||||||
let timeoutId;
|
|
||||||
return (...args) => {
|
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
fn(...args);
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamic form component with auto submission and validation.
|
|
||||||
*
|
|
||||||
* props
|
|
||||||
* .fields => List of field objects
|
|
||||||
* .channel => e.g. "/login"
|
|
||||||
* .modifier => Form description
|
|
||||||
*/
|
|
||||||
const SmartForm =
|
|
||||||
{
|
|
||||||
props: ["fields", "channel", "modifier"],
|
|
||||||
|
|
||||||
setup(props)
|
|
||||||
{
|
|
||||||
// Error log below form
|
|
||||||
const log = Vue.ref(props.modifier);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid and submit form data to channel.
|
|
||||||
*/
|
|
||||||
const submit = async () => {
|
|
||||||
|
|
||||||
// Validate each field
|
|
||||||
for (let field of props.fields) {
|
|
||||||
const error = field.valid(field.value);
|
|
||||||
if (error) return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduce fields into the form
|
|
||||||
const form = props.fields.reduce((v, n) => {
|
|
||||||
v[n.name] = n.value;
|
|
||||||
return v;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
console.log("Submitting =>", form);
|
|
||||||
return await window.mainApi.invoke(props.channel, form);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper for submit that is debounced and updates UI elements
|
|
||||||
*/
|
|
||||||
const preSub = debounce(async () => {
|
|
||||||
|
|
||||||
const error = await submit();
|
|
||||||
|
|
||||||
log.value = error ? error : props.modifier
|
|
||||||
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return { preSub, log };
|
|
||||||
},
|
|
||||||
|
|
||||||
template: `
|
|
||||||
<form id="smart-form" class="smart-form" v-on:input="preSub">
|
|
||||||
|
|
||||||
<div v-for="field in fields" style="display:flex">
|
|
||||||
|
|
||||||
<input type="text" class="text-field"
|
|
||||||
v-model="field.value"
|
|
||||||
:name="field.name"
|
|
||||||
:placeholder="field.placeholder"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="smart-form-logs">{{ log }}</div>
|
|
||||||
|
|
||||||
</form>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SmartForm;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
|
||||||
89
src/render/components/header.vue
Normal 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>
|
||||||
|
|
@ -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;
|
|
||||||
220
src/render/components/inputItem.vue
Normal 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>
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import SmartForm from "./form.js";
|
|
||||||
import { isEmail, isPassword, isName } from "./control/valid.js";
|
|
||||||
|
|
||||||
const Login =
|
|
||||||
{
|
|
||||||
components: {
|
|
||||||
SmartForm
|
|
||||||
},
|
|
||||||
|
|
||||||
setup()
|
|
||||||
{
|
|
||||||
return { isEmail, isPassword, isName };
|
|
||||||
},
|
|
||||||
|
|
||||||
template: `
|
|
||||||
<div id="login">
|
|
||||||
|
|
||||||
<SmartForm
|
|
||||||
:fields="[{
|
|
||||||
name: 'email',
|
|
||||||
value: null,
|
|
||||||
placeholder: 'Email',
|
|
||||||
valid: isEmail
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'password',
|
|
||||||
value: null,
|
|
||||||
placeholder: 'Password',
|
|
||||||
valid: isPassword
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
value: null,
|
|
||||||
placeholder: 'Name',
|
|
||||||
valid: isName
|
|
||||||
}]"
|
|
||||||
:channel="'auth'"
|
|
||||||
:modifier="'Login or enter name to register.'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Login;
|
|
||||||
180
src/render/components/login.vue
Normal 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>
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
82
src/render/components/messenger.vue
Normal 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>
|
||||||
|
|
@ -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;
|
|
||||||
164
src/render/components/settings.vue
Normal 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>
|
||||||
|
|
||||||
|
|
@ -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;
|
|
||||||
69
src/render/components/splash.vue
Normal 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>
|
||||||
|
|
@ -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;
|
|
||||||
199
src/render/composables/useDraggify.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
54
src/render/composables/useIpcRend.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/render/composables/useMessages.ts
Normal 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
|
||||||
|
};
|
||||||
27
src/render/composables/useProfile.ts
Normal 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,
|
||||||
|
};
|
||||||
29
src/render/composables/useScroll.ts
Normal 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import App from "./components/app.js";
|
|
||||||
|
|
||||||
window.path = "./";
|
|
||||||
|
|
||||||
Vue.createApp(App).mount("#app");
|
|
||||||
80
src/render/ipc.ts
Normal 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
|
|
@ -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
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -1,578 +0,0 @@
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Default";
|
|
||||||
src: url("assets/fonts/SF-Pro-Text-Regular.otf");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Compact";
|
|
||||||
src: url("assets/fonts/SF-Compact-Display-Bold.otf");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Rounded";
|
|
||||||
src: url("assets/fonts/SF-Compact-Rounded-Bold.otf");
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
/*background-color: rgba(235, 235, 235, 0.75);*/
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The first word of the class is the component that it modifys. */
|
|
||||||
|
|
||||||
#splash {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
position: relative; /* must explicitly be declared */
|
|
||||||
font-family: "Default";
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
height: 100vh; /* 100% for website */
|
|
||||||
width: 100vw; /* 100% for website */
|
|
||||||
/* Does not work on Linux */
|
|
||||||
border-radius: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header-titlebar {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 54px;
|
|
||||||
opacity: 0.75;
|
|
||||||
background-color: #EBEBEB;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
z-index: 1;
|
|
||||||
border-top-left-radius: 15px;
|
|
||||||
border-top-right-radius: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: inherit;
|
|
||||||
background-color: #DBDBDB;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts > li {
|
|
||||||
padding: 12px;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts img {
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts .info {
|
|
||||||
margin-left: 10px;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts .name {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts .email {
|
|
||||||
font-family: "Compact";
|
|
||||||
font-size: 12px;
|
|
||||||
color: #898989;
|
|
||||||
overflow: w;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header-menu {
|
|
||||||
position: absolute;
|
|
||||||
margin-left: 20px;
|
|
||||||
margin-top: 20px;
|
|
||||||
z-index: 2;
|
|
||||||
/* Needed for Linux even with higher Z index */
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-menu-button {
|
|
||||||
min-width: 14px;
|
|
||||||
min-height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-exit-button {
|
|
||||||
background-color: #FF6157;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-exit-button:active {
|
|
||||||
background: #c14645;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-min-button {
|
|
||||||
background-color: #FFC12F;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-min-button:active {
|
|
||||||
background-color: #c08e38;
|
|
||||||
}
|
|
||||||
|
|
||||||
#login {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#messenger {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* hide native scrollbar */
|
|
||||||
#messenger::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ws {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 54px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ws-logo {
|
|
||||||
transition: all 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ws-logo.move {
|
|
||||||
transform: translateX(15px);
|
|
||||||
}
|
|
||||||
|
|
||||||
#ws-dots {
|
|
||||||
opacity: 0;
|
|
||||||
transition: all 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ws-dots.move {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(-15px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #48E065;
|
|
||||||
position: relative;
|
|
||||||
transform: translateX(-15px);
|
|
||||||
animation: ws-dot-flashing 1s infinite linear alternate;
|
|
||||||
animation-delay: .25s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-dot::before, .ws-dot::after {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
position: absolute;
|
|
||||||
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-dot::before {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #48E065;
|
|
||||||
left: -9px;
|
|
||||||
animation: ws-dot-flashing 1s infinite alternate;
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-dot::after {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #48E065;
|
|
||||||
left: 9px;
|
|
||||||
animation: ws-dot-flashing 1s infinite alternate;
|
|
||||||
animation-delay: 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ws-dot-flashing {
|
|
||||||
0% {
|
|
||||||
background-color: #48E065;
|
|
||||||
}
|
|
||||||
50%,
|
|
||||||
100% {
|
|
||||||
background-color: #9B9B9B;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#input {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
z-index: 3;
|
|
||||||
/* To prevent window drag when overlapping with titlebar. */
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
border-radius: 50%;
|
|
||||||
/* Set opacity here to not affect child. */
|
|
||||||
background-color: rgba(235, 235, 235, 0.75);
|
|
||||||
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
#input-text {
|
|
||||||
position: absolute;
|
|
||||||
min-width: 150px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 10px;
|
|
||||||
outline: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 14px;
|
|
||||||
pointer-events: none;
|
|
||||||
background-color: white;
|
|
||||||
z-index: -1;
|
|
||||||
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
|
|
||||||
transform-origin: center;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(70%);
|
|
||||||
transition: all 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
#input-text.show {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#input-text.leftSide {
|
|
||||||
transform: translateX(-70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#settings {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(235, 235, 235, 0.75);
|
|
||||||
z-index: 4;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
animation-name: settings-appear;
|
|
||||||
animation-duration: 0.5s;
|
|
||||||
border-radius: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes settings-appear {
|
|
||||||
from {
|
|
||||||
background-color: rgba(235, 235, 235, 0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
background-color: rgba(235, 235, 235, 0.75);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-icon {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
padding: 5px;
|
|
||||||
margin-right: 20px;
|
|
||||||
margin-top: 19px;
|
|
||||||
z-index: 2;
|
|
||||||
background-color: Transparent;
|
|
||||||
/* Needed for Linux */
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-account {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-logout-button {
|
|
||||||
font-family: "Compact";
|
|
||||||
background-color: #B7B7B7;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-version {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 75%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #575757;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
width: inherit;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding-top: 9px;
|
|
||||||
padding-bottom: 9px;
|
|
||||||
animation-name: message-init-anim;
|
|
||||||
animation-duration: 0.25s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes message-init-anim {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
} to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message:first-child {
|
|
||||||
margin-top: 55px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message:last-child {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-session {
|
|
||||||
width: 100vw;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-family: "Compact";
|
|
||||||
font-size: 12px;
|
|
||||||
color: #9B9B9B;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-message {
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-message {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-message {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
|
||||||
position: relative;
|
|
||||||
max-width: 66%;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 18px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-notify {
|
|
||||||
position: absolute;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #58D9FF;
|
|
||||||
top: -5px;
|
|
||||||
left: -5px;
|
|
||||||
border: 2px solid #EBEBEB;
|
|
||||||
transform: scale(0);
|
|
||||||
animation-name: bubble-notify-anim;
|
|
||||||
animation-duration: 5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bubble-notify-anim {
|
|
||||||
0%, 90% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-bubble {
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
margin-left: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-bubble {
|
|
||||||
color: white;
|
|
||||||
background-color: #58C4FD;
|
|
||||||
margin-right: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-first-child {
|
|
||||||
border-bottom-left-radius: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-middle-child {
|
|
||||||
border-top-left-radius: 9px;
|
|
||||||
border-bottom-left-radius: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-last-child {
|
|
||||||
border-top-left-radius: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-first-child {
|
|
||||||
border-bottom-right-radius: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-middle-child {
|
|
||||||
border-top-right-radius: 9px;
|
|
||||||
border-bottom-right-radius: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-last-child {
|
|
||||||
border-top-right-radius: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble > p {
|
|
||||||
margin: 0px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble > div {
|
|
||||||
border-radius: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble > img {
|
|
||||||
border-radius: inherit;
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble a {
|
|
||||||
display: inline-block;
|
|
||||||
font-family: "Rounded";
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: inherit;
|
|
||||||
background-color: #D9D9D9;
|
|
||||||
padding: 10px;
|
|
||||||
color: #727272;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context {
|
|
||||||
position: relative;
|
|
||||||
min-height: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-family: "Compact";
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-context {
|
|
||||||
margin-left: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-context {
|
|
||||||
margin-right: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-avatar {
|
|
||||||
margin-right: 5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- shared between components ---------- */
|
|
||||||
|
|
||||||
.button {
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*.button:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
/* shake a div to signal error*/
|
|
||||||
.shake {
|
|
||||||
animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
|
|
||||||
transform: translate3d(0, 0, 0);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
perspective: 1000px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shake {
|
|
||||||
10%, 90% {
|
|
||||||
transform: translate3d(-1px, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
20%, 80% {
|
|
||||||
transform: translate3d(2px, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
30%, 50%, 70% {
|
|
||||||
transform: translate3d(-4px, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
40%, 60% {
|
|
||||||
transform: translate3d(4px, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio {
|
|
||||||
animation-name: audio-anim;
|
|
||||||
animation-duration: 2s;
|
|
||||||
animation-iteration-count: infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes audio-anim {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0.4em rgba(195, 195, 195, 0.4), 0 0 0 0.25em rgba(195, 195, 195, 0.15);
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
box-shadow: 0 0 0 0.15em rgba(195, 195, 195, 0.15), 0 0 0 0.4em rgba(195, 195, 195, 0.3);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 0 0 0.55em rgba(195, 195, 195, 0.55), 0 0 0 0.15em rgba(195, 195, 195, 0.05);
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
box-shadow: 0 0 0 0.25em rgba(195, 195, 195, 0.25), 0 0 0 0.55em rgba(195, 195, 195, 0.45);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.smart-form {
|
|
||||||
width: 225px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-field {
|
|
||||||
width: 100%;
|
|
||||||
padding: 16px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
margin: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background-color: #d1d1d1;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.smart-form-logs {
|
|
||||||
font-family: "Compact";
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: left;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||