init commit for Forge branch
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.DS_Store
|
||||
/node_modules
|
||||
/out
|
||||
2
.npmrc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@crimata:registry=https://gitlab.com/api/v4/projects/28849281/packages/npm/
|
||||
//gitlab.com/api/v4/projects/28849281/packages/npm
|
||||
14
entitlements.plist
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.debugger</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
<!--<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>-->
|
||||
BIN
icon.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
1
main.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
console.log("Hello");
|
||||
12662
package-lock.json
generated
Normal file
65
package.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"name": "crimata",
|
||||
"productName": "Crimata",
|
||||
"version": "1.0.0",
|
||||
"description": "Cross-platform messenger app (Electron, Vue3)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make"
|
||||
},
|
||||
"author": "Andrew Gundersen",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^6.0.0-beta.63",
|
||||
"@electron-forge/maker-deb": "^6.0.0-beta.63",
|
||||
"@electron-forge/maker-rpm": "^6.0.0-beta.63",
|
||||
"@electron-forge/maker-squirrel": "^6.0.0-beta.63",
|
||||
"@electron-forge/maker-zip": "^6.0.0-beta.63",
|
||||
"electron": "^18.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-squirrel-startup": "^1.0.0"
|
||||
},
|
||||
"config": {
|
||||
"forge": {
|
||||
"packagerConfig": {
|
||||
"osxSign": {
|
||||
"identity": "Developer ID Application: Felix Rieseberg (LT94ZKYDCJ)",
|
||||
"hardened-runtime": true,
|
||||
"entitlements": "entitlements.plist",
|
||||
"entitlements-inherit": "entitlements.plist",
|
||||
"signature-flags": "library"
|
||||
},
|
||||
"osxNotarize": {
|
||||
"appleId": "felix@felix.fun",
|
||||
"appleIdPassword": "my-apple-id-password",
|
||||
}
|
||||
},
|
||||
"makers": [
|
||||
{
|
||||
"name": "@electron-forge/maker-squirrel",
|
||||
"config": {
|
||||
"name": "crimata"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "@electron-forge/maker-zip",
|
||||
"platforms": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@electron-forge/maker-deb",
|
||||
"config": {}
|
||||
},
|
||||
{
|
||||
"name": "@electron-forge/maker-rpm",
|
||||
"config": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/account.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
const { ipcMain } = require("electron");
|
||||
|
||||
const { post } = require("./api");
|
||||
const store = require("./utils/store");
|
||||
const { launchSession, endSession } = require("./session");
|
||||
const { backgroundMitt, ipcEmit } = require("./utils/emitter");
|
||||
|
||||
async function auth(_e, creds)
|
||||
{
|
||||
const { error, data } = await post("/auth", creds);
|
||||
|
||||
if (error)
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
store.set("account", data);
|
||||
ipcEmit("account", data);
|
||||
launchSession(data);
|
||||
};
|
||||
|
||||
function logout(_e, reason)
|
||||
{
|
||||
store.delete("account");
|
||||
ipcEmit("account", false, reason);
|
||||
endSession();
|
||||
};
|
||||
|
||||
function initAccount()
|
||||
{
|
||||
ipcMain.handle("auth", auth);
|
||||
ipcMain.on("logout", logout);
|
||||
|
||||
const account = store.get("account");
|
||||
if (account) launchSession(account);
|
||||
}
|
||||
|
||||
backgroundMitt.on("logout", (reason) => logout(null, reason));
|
||||
|
||||
|
||||
exports.initAccount = initAccount;
|
||||
38
src/api.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
const axios = require('axios').default;
|
||||
|
||||
const config = require("./config");
|
||||
|
||||
|
||||
async function post(route, body)
|
||||
{
|
||||
let error;
|
||||
let data;
|
||||
|
||||
try
|
||||
{
|
||||
const res = await axios.post(config.API + route, body);
|
||||
data = res.data;
|
||||
error = false;
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
if (e.response)
|
||||
{
|
||||
data = e.response.data;
|
||||
}
|
||||
else if (e.request)
|
||||
{
|
||||
data = "Can't connect to the server."
|
||||
}
|
||||
else
|
||||
{
|
||||
data = "Unknown error occured."
|
||||
}
|
||||
|
||||
error = true;
|
||||
}
|
||||
|
||||
return { error, data };
|
||||
}
|
||||
|
||||
exports.post = post;
|
||||
BIN
src/assets/icon.icns
Normal file
BIN
src/assets/tray/000.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/assets/tray/000@2x.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
src/assets/tray/001.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/assets/tray/001@2x.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/assets/tray/010.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/assets/tray/010@2x.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
src/assets/tray/011.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/assets/tray/011@2x.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src/assets/tray/100.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/assets/tray/100@2x.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src/assets/tray/101.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/assets/tray/101@2x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src/assets/tray/110.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/assets/tray/110@2x.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src/assets/tray/111.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/assets/tray/111@2x.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
134
src/audio.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
const nodeAudio = require("@crimata/nodeaudio");
|
||||
const { globalShortcut, ipcMain } = require("electron");
|
||||
|
||||
const { sendMessage } = require("./io");
|
||||
const { updateTray } = require("./tray");
|
||||
const { encode, decode } = require("./codec");
|
||||
const { backgroundMitt, ipcEmit } = require("./utils/emitter");
|
||||
|
||||
let inputDevice;
|
||||
let outputDevice;
|
||||
|
||||
let setWriteId;
|
||||
let setStreamsId;
|
||||
|
||||
let autoStopId; /* keep track of recording time */
|
||||
|
||||
let playbackId; /* UID of the message being played */
|
||||
|
||||
const streamState = { rec: false, pb: false };
|
||||
|
||||
const /** @type {Int16Array[]} */ chunks = [];
|
||||
|
||||
backgroundMitt.on("data", (int16Arr) => {
|
||||
if (streamState.rec) chunks.push(int16Arr);
|
||||
});
|
||||
|
||||
backgroundMitt.on("write", (int16Arr) => {
|
||||
clearTimeout(setWriteId);
|
||||
setWriteId = setTimeout(() => {
|
||||
setPlaybackStatus(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
function setStreams()
|
||||
{
|
||||
const defaultInput = nodeAudio.core.GetDefaultInputDevice();
|
||||
const defaultOutput = nodeAudio.core.GetDefaultOutputDevice();
|
||||
|
||||
if (inputDevice !== defaultInput)
|
||||
{
|
||||
inputDevice = defaultInput;
|
||||
nodeAudio.core.CloseInputStream(inputDevice);
|
||||
nodeAudio.core.OpenInputStream(inputDevice);
|
||||
}
|
||||
|
||||
if (outputDevice !== defaultOutput)
|
||||
{
|
||||
outputDevice = defaultOutput;
|
||||
nodeAudio.core.CloseOutputStream(outputDevice);
|
||||
nodeAudio.core.OpenOutputStream(outputDevice);
|
||||
}
|
||||
}
|
||||
|
||||
function startRecording()
|
||||
{
|
||||
setRecordingStatus(true);
|
||||
|
||||
/* 15s recording time limit */
|
||||
autoStopId = setTimeout(stopRecording, 15000);
|
||||
}
|
||||
|
||||
function stopRecording()
|
||||
{
|
||||
if (autoStopId)
|
||||
{
|
||||
clearTimeout(autoStopId);
|
||||
}
|
||||
|
||||
setRecordingStatus(false);
|
||||
|
||||
sendMessage({
|
||||
category: "audio",
|
||||
text: null,
|
||||
blob: encode(nodeAudio.utils.mergeChunks(chunks).buffer)
|
||||
});
|
||||
|
||||
chunks.length = 0;
|
||||
}
|
||||
|
||||
function initAudio()
|
||||
{
|
||||
nodeAudio.core.Initialize(backgroundMitt.emit.bind(backgroundMitt));
|
||||
setStreamsId = setInterval(setStreams, 2000);
|
||||
|
||||
const res = globalShortcut.register('CommandOrControl+Return', () => {
|
||||
streamState.rec ? stopRecording() : startRecording();
|
||||
});
|
||||
|
||||
if (!res) throw new Error("Failed to register recording shortcut");
|
||||
}
|
||||
|
||||
function playback(base64String, id)
|
||||
{
|
||||
/* terminate any current playback */
|
||||
nodeAudio.core.CancelPlayback();
|
||||
ipcEmit("playback", playbackId, false);
|
||||
|
||||
playbackId = id;
|
||||
setPlaybackStatus(true);
|
||||
nodeAudio.core.WriteToOutputStream(decode(base64String));
|
||||
}
|
||||
|
||||
function terminateAudio()
|
||||
{
|
||||
globalShortcut.unregisterAll();
|
||||
|
||||
/* Only terminate PA if initialized */
|
||||
if (setStreamsId)
|
||||
{
|
||||
clearInterval(setStreamsId);
|
||||
nodeAudio.core.Terminate();
|
||||
}
|
||||
}
|
||||
|
||||
function setRecordingStatus(status)
|
||||
{
|
||||
streamState.rec = status;
|
||||
ipcEmit("record", status);
|
||||
updateTray("recording", streamState.rec);
|
||||
}
|
||||
|
||||
function setPlaybackStatus(status)
|
||||
{
|
||||
console.log("Setting playback status: ", status);
|
||||
|
||||
streamState.pb = status;
|
||||
ipcEmit("playback", playbackId, status);
|
||||
updateTray("playback", status);
|
||||
}
|
||||
|
||||
exports.initAudio = initAudio;
|
||||
exports.terminateAudio = terminateAudio;
|
||||
exports.playback = playback;
|
||||
exports.streamState = streamState;
|
||||
13
src/codec.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
const { ipcMain } = require("electron");
|
||||
const { encode, decode } = require("base64-arraybuffer");
|
||||
|
||||
ipcMain.handle("encode", (_e, arrayBuffer) => {
|
||||
return encode(arrayBuffer);
|
||||
});
|
||||
|
||||
ipcMain.handle("decode", (_e, b64string) => {
|
||||
return decode(b64string);
|
||||
});
|
||||
|
||||
exports.encode = encode;
|
||||
exports.decode = decode;
|
||||
11
src/config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const { app } = require("electron");
|
||||
|
||||
const DOMAIN = "crimata.com";
|
||||
|
||||
const prod = !process.defaultApp;
|
||||
// const prod = true;
|
||||
|
||||
module.exports = {
|
||||
PLATFORM: prod ? `https://app.${DOMAIN}` : `http://localhost:8760`,
|
||||
API: prod ? `https://${DOMAIN}/api` : `http://localhost:8761`
|
||||
}
|
||||
69
src/io.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
const WebSocket = require("ws");
|
||||
const { ipcMain } = require("electron");
|
||||
|
||||
const config = require("./config");
|
||||
const { updateTray } = require("./tray");
|
||||
const { backgroundMitt, ipcEmit } = require("./utils/emitter");
|
||||
|
||||
let connection = null;
|
||||
|
||||
let pingId;
|
||||
let reconnectId;
|
||||
|
||||
function connectToPlatform(account)
|
||||
{
|
||||
if (pingId) clearInterval(pingId);
|
||||
|
||||
connection = new WebSocket(`${config.PLATFORM}/${account}`)
|
||||
|
||||
.on("open", () => pingId = setInterval(() => connection.ping(null, true), 1000))
|
||||
|
||||
.on("pong", () => setConnectionStatus(0))
|
||||
|
||||
.on("error", () => {}) /** keep silent on error */
|
||||
|
||||
.on("message", (payload) => backgroundMitt.emit("message", JSON.parse(payload)))
|
||||
|
||||
.on("close", (_code, reason) => {
|
||||
|
||||
setConnectionStatus(1);
|
||||
|
||||
if (reason)
|
||||
{
|
||||
if (reason == "unauthorized")
|
||||
{
|
||||
backgroundMitt.emit("logout", reason);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectId = setTimeout(() => connectToPlatform(account), 500);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
function sendMessage(message)
|
||||
{
|
||||
if (connection.readyState === WebSocket.OPEN) {
|
||||
connection.send(JSON.stringify(message));
|
||||
} else console.log("Failed to send message");
|
||||
}
|
||||
|
||||
function disconnectFromPlatform()
|
||||
{
|
||||
clearTimeout(reconnectId);
|
||||
if (connection.open) connection.close(1000, "logout");
|
||||
}
|
||||
|
||||
function setConnectionStatus(status)
|
||||
{
|
||||
updateTray("disconnect", status);
|
||||
ipcEmit("ws", status);
|
||||
}
|
||||
|
||||
ipcMain.on("message", (_e, message) => sendMessage(message));
|
||||
|
||||
exports.connectToPlatform = connectToPlatform;
|
||||
exports.sendMessage = sendMessage;
|
||||
exports.disconnectFromPlatform = disconnectFromPlatform;
|
||||
28
src/main.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
const { app, protocol, ipcMain, nativeTheme } = require("electron");
|
||||
|
||||
const store = require("./utils/store");
|
||||
|
||||
const { initTray } = require("./tray");
|
||||
const { createWin } = require("./window");
|
||||
const { initAccount } = require("./account");
|
||||
const { terminateAudio } = require("./audio");
|
||||
|
||||
// Assert a light theme
|
||||
nativeTheme.themeSource = "light";
|
||||
|
||||
app.on("ready", () =>
|
||||
{
|
||||
initTray();
|
||||
|
||||
createWin();
|
||||
|
||||
initAccount();
|
||||
});
|
||||
|
||||
app.on("will-quit", terminateAudio);
|
||||
|
||||
// When user clicks app icon (re-open)
|
||||
app.on("activate", createWin);
|
||||
|
||||
// Prevents app from quitting on window close event
|
||||
app.on('window-all-closed', (e) => e.preventDefault());
|
||||
12
src/render/assets/connectLogo.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg id="logo" xmlns="http://www.w3.org/2000/svg" width="24.87" height="23.001" viewBox="0 0 24.87 23.001">
|
||||
<g id="Group_33" data-name="Group 33">
|
||||
<g id="Group_32" data-name="Group 32" transform="translate(0 0)">
|
||||
<g id="Group_31" data-name="Group 31" transform="translate(0 11.948)">
|
||||
<circle id="Ellipse_50" data-name="Ellipse 50" cx="5.527" cy="5.527" r="5.527" transform="translate(0 0)" fill="#383838"/>
|
||||
<circle id="Ellipse_51" data-name="Ellipse 51" cx="5.527" cy="5.527" r="5.527" transform="translate(13.817 0)" fill="#383838"/>
|
||||
</g>
|
||||
<circle id="Ellipse_52" data-name="Ellipse 52" cx="5.527" cy="5.527" r="5.527" transform="translate(6.898)" fill="#383838"/>
|
||||
<path id="Path_150" data-name="Path 150" d="M36.27,83.67" transform="translate(-30.733 -66.196)" fill="#ff0"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 845 B |
BIN
src/render/assets/fonts/SF-Compact-Display-Bold.otf
Executable file
BIN
src/render/assets/fonts/SF-Compact-Rounded-Bold.otf
Executable file
BIN
src/render/assets/fonts/SF-Pro-Text-Regular.otf
Executable file
7
src/render/assets/settingsIcon.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="5" viewBox="0 0 25 5">
|
||||
<g id="Group_611" data-name="Group 611" transform="translate(-4032 499)">
|
||||
<circle id="Ellipse_71" data-name="Ellipse 71" cx="2.5" cy="2.5" r="2.5" transform="translate(4032 -499)" fill="#9b9b9b"/>
|
||||
<circle id="Ellipse_72" data-name="Ellipse 72" cx="2.5" cy="2.5" r="2.5" transform="translate(4042 -499)" fill="#9b9b9b"/>
|
||||
<circle id="Ellipse_73" data-name="Ellipse 73" cx="2.5" cy="2.5" r="2.5" transform="translate(4052 -499)" fill="#9b9b9b"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 553 B |
44
src/render/components/app.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import Login from "./login.js";
|
||||
import Splash from "./splash.js";
|
||||
import Header from "./header.js";
|
||||
import Messenger from "./messenger.js";
|
||||
|
||||
const account = Vue.ref(null);
|
||||
|
||||
const App =
|
||||
{
|
||||
components: {
|
||||
Splash,
|
||||
Header,
|
||||
Messenger,
|
||||
Login
|
||||
},
|
||||
|
||||
setup()
|
||||
{
|
||||
return { account };
|
||||
},
|
||||
|
||||
template: `
|
||||
<div id="app" v-if="account !== null">
|
||||
|
||||
<Header />
|
||||
|
||||
<Messenger v-if="account"/>
|
||||
<Login v-else />
|
||||
|
||||
</div>
|
||||
|
||||
<Splash v-else />`
|
||||
}
|
||||
|
||||
window.mainApi.on("account", (value, reason) => {
|
||||
if (reason) alert(reason);
|
||||
account.value = value;
|
||||
});
|
||||
|
||||
export default App;
|
||||
export { account };
|
||||
|
||||
// Is called when window is about to close or reload
|
||||
window.onbeforeunload = () => console.log("beforeunload");
|
||||
48
src/render/components/bubble.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
const Bubble =
|
||||
{
|
||||
props: ["category", "text", "html", "blob", "modifier", "child"],
|
||||
|
||||
setup(props)
|
||||
{
|
||||
const getUrl = () => {
|
||||
return `data:${props.category};base64,${props.blob}`;
|
||||
}
|
||||
|
||||
Vue.onMounted(() => {
|
||||
|
||||
const el = document.getElementById("messenger");
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
el.dispatchEvent(new CustomEvent('adjust-scroll'));
|
||||
|
||||
}, 100);
|
||||
|
||||
});
|
||||
|
||||
if (!props.text) props.text = "...";
|
||||
|
||||
return { getUrl };
|
||||
},
|
||||
|
||||
template: `
|
||||
<div :class="'bubble ' + modifier + '-bubble ' + modifier +'-'+ child">
|
||||
|
||||
<small class="bubble-notify" v-if="modifier=='ai'"></small>
|
||||
|
||||
<p v-if="category=='text'">{{ text }}</p>
|
||||
|
||||
<p v-else-if="category=='audio'">{{ text }}</p>
|
||||
|
||||
<div v-else-if="category=='html'" v-html="html"></div>
|
||||
|
||||
<img v-else-if="category == 'image'" :src="getUrl()" />
|
||||
|
||||
<a v-else :download="text" :href="getUrl()">{{ text }}</a>
|
||||
|
||||
</div>`
|
||||
};
|
||||
|
||||
export default Bubble;
|
||||
|
||||
// TODO: should include other styling/actions for audio bubbles
|
||||
33
src/render/components/context.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
|
||||
const Context =
|
||||
{
|
||||
props: ["modifier", "context", "avatar", "id"],
|
||||
|
||||
setup(props) {
|
||||
const playback = Vue.ref(false);
|
||||
|
||||
Vue.onMounted(() => {
|
||||
window.mainApi.on("playback", (id, status) => {
|
||||
if (id === props.id) {
|
||||
playback.value = status;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { playback };
|
||||
},
|
||||
|
||||
template: `
|
||||
<span :class="'context ' + modifier + '-context'">
|
||||
|
||||
<img v-show="avatar" class="context-avatar"
|
||||
:class="{ audio: playback }"
|
||||
:src="'data:image/png;base64,' + avatar"
|
||||
/>
|
||||
|
||||
{{ context }}
|
||||
|
||||
</span> `
|
||||
}
|
||||
|
||||
export default Context;
|
||||
195
src/render/components/control/draggify.js
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
const saveLocation = "input_item_position";
|
||||
const defaultPosition = { x: 15, y: 400 };
|
||||
|
||||
//---Dragabble Helper Funcs--------------------------------------
|
||||
|
||||
// Calculate distance to nearest side.
|
||||
function calcSideProximity (elementX, elementLength, winW) {
|
||||
|
||||
// Calc right short.
|
||||
let short = winW - elementX - elementLength;
|
||||
|
||||
// See if it's left short.
|
||||
if (elementX + 20 < winW / 2) {
|
||||
short = elementX
|
||||
}
|
||||
|
||||
return short
|
||||
}
|
||||
|
||||
// Update elementX or elementY value on window resize
|
||||
function calcPosition (elementPosition, elementLength, percent, short, win) {
|
||||
|
||||
// Is it close to the right/bottom side?
|
||||
if (percent > 0.75) {
|
||||
elementPosition = win - short - elementLength;
|
||||
}
|
||||
|
||||
// Is it not close to a side?
|
||||
if (percent < 0.75 && percent > 0.25) {
|
||||
elementPosition = win * percent
|
||||
}
|
||||
|
||||
return elementPosition
|
||||
}
|
||||
|
||||
function draggify(elementId, parentId, margin) {
|
||||
|
||||
let element;
|
||||
let parent;
|
||||
|
||||
/* only compatible with elements having equal width and height */
|
||||
let elementLength;
|
||||
|
||||
// Cords of inputItem.
|
||||
const elementX = Vue.ref();
|
||||
const elementY = Vue.ref();
|
||||
|
||||
// Position of inputItem on terms of percentage of window.
|
||||
let percentX;
|
||||
let percentY;
|
||||
|
||||
// How close inputItem is to closest X or Y side.
|
||||
let xShort;
|
||||
let yShort;
|
||||
|
||||
//---Reposition Anime-----------------------------------------------
|
||||
|
||||
// Move element to target smoothly.
|
||||
const repositionAnime = (xChange, yChange) => {
|
||||
|
||||
const xStep = xChange / 1000;
|
||||
const yStep = yChange / 1000;
|
||||
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
|
||||
setTimeout(() => {
|
||||
elementX.value += xStep;
|
||||
elementY.value += yStep;
|
||||
}, 30) // 60fps
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
//---Event Handlers-----------------------------------------------
|
||||
|
||||
// Update the position of inputItem on mouse dragging.
|
||||
const onMouseMove = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
elementX.value = element.offsetLeft + e.movementX;
|
||||
elementY.value = element.offsetTop + e.movementY;
|
||||
}
|
||||
|
||||
// Add an event listener for dragging.
|
||||
const onMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
// Update position references when user is done moving targetEl.
|
||||
const onMouseUp = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
// See if and calculate reposition.
|
||||
let x = 0; // vector change
|
||||
let y = 0;
|
||||
|
||||
const winW = parent.clientWidth;
|
||||
const winH = parent.clientHeight;
|
||||
|
||||
if (elementX.value < 0) {
|
||||
x = (elementX.value - margin)*-1
|
||||
}
|
||||
|
||||
if (elementX.value > winW - elementLength) {
|
||||
const b = winW - elementLength - margin;
|
||||
x = (elementX.value - b)*-1
|
||||
}
|
||||
|
||||
// Reposition y
|
||||
if (elementY.value < 0) {
|
||||
y = (elementY.value - margin)*-1
|
||||
}
|
||||
|
||||
if (elementY.value > winH - elementLength) {
|
||||
const d = winH - elementLength - margin;
|
||||
y = (elementY.value - d)*-1
|
||||
}
|
||||
|
||||
// Reposition if needed.
|
||||
if (x !== 0 || y !== 0) repositionAnime(x, y);
|
||||
|
||||
xShort = calcSideProximity(elementX.value, elementLength, winW);
|
||||
yShort = calcSideProximity(elementY.value, elementLength, winH);
|
||||
|
||||
// Update percentages.
|
||||
percentX = elementX.value / winW;
|
||||
percentY = elementY.value / winH;
|
||||
|
||||
// Save position.
|
||||
savePosition();
|
||||
}
|
||||
|
||||
// Update position of targetEl on windowResize.
|
||||
const onWindowResize = (_e) => {
|
||||
|
||||
elementX.value = calcPosition(elementX.value, elementLength, percentX, xShort, parent.clientWidth);
|
||||
elementY.value = calcPosition(elementY.value, elementLength, percentY, yShort, parent.clientHeight);
|
||||
|
||||
savePosition();
|
||||
}
|
||||
|
||||
const savePosition = () => {
|
||||
window.localStorage.setItem(saveLocation, JSON.stringify({
|
||||
x: elementX.value,
|
||||
y: elementY.value
|
||||
}));
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
|
||||
Vue.onMounted(() => {
|
||||
|
||||
element = document.getElementById(elementId);
|
||||
parent = document.getElementById(parentId);
|
||||
|
||||
elementLength = element.offsetWidth;
|
||||
|
||||
// Initialize the positional references.
|
||||
percentX = elementX.value / parent.clientWidth;
|
||||
percentY = elementY.value / parent.clientHeight;
|
||||
|
||||
xShort = calcSideProximity(elementX.value, elementLength, parent.clientWidth);
|
||||
yShort = calcSideProximity(elementX.value, elementLength, parent.clientHeight);
|
||||
|
||||
// Then, we can listen for window resize (and mousedown).
|
||||
element.addEventListener("mousedown", onMouseDown);
|
||||
parent.addEventListener('resize', onWindowResize)
|
||||
});
|
||||
|
||||
// remove event listeners on component dismount.
|
||||
Vue.onUnmounted(() => {
|
||||
element.removeEventListener('mousedown', onMouseDown);
|
||||
parent.removeEventListener('resize', onWindowResize)
|
||||
parent.removeEventListener('mouseup', onMouseUp)
|
||||
parent.removeEventListener('mousemove', onMouseMove);
|
||||
});
|
||||
|
||||
// Try loading initPosition, otherwise set default values
|
||||
let initPosition;
|
||||
const rawData = window.localStorage.getItem("saveLocation")
|
||||
rawData ? initPosition = JSON.parse(rawData) : initPosition = defaultPosition;
|
||||
|
||||
elementX.value = initPosition.x;
|
||||
elementY.value = initPosition.y;
|
||||
|
||||
return { elementX, elementY };
|
||||
|
||||
}
|
||||
|
||||
export default draggify;
|
||||
24
src/render/components/control/drop.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const onDrop = async (e) => {
|
||||
const file = e.dataTransfer.items[0].getAsFile();
|
||||
|
||||
if (file.size >= 1 * 1000 * 1000) {
|
||||
alert("File must be under 1MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
const b64String = await window.mainApi.invoke("encode", buffer);
|
||||
|
||||
let category = "file";
|
||||
if (file.type.startsWith("image/")) {
|
||||
category = "image"
|
||||
}
|
||||
|
||||
window.mainApi.send("message", {
|
||||
category: category,
|
||||
text: file.name,
|
||||
blob: b64String
|
||||
});
|
||||
}
|
||||
|
||||
export default onDrop;
|
||||
20
src/render/components/control/scroll.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
let el;
|
||||
|
||||
const scroll = () => {
|
||||
if (!el) {
|
||||
el = document.getElementById("messenger");
|
||||
window.addEventListener('resize', scroll);
|
||||
}
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight - el.clientHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
export default scroll;
|
||||
|
||||
// const isBottom = () => {
|
||||
// if (el) {
|
||||
// return el.scrollHeight - el.clientHeight <= el.scrollTop + 1;
|
||||
// }
|
||||
// }
|
||||
84
src/render/components/control/text.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
const inputLength = 230;
|
||||
|
||||
const metaKeys = [
|
||||
"Tab",
|
||||
"CapsLock",
|
||||
"Shift",
|
||||
"Control",
|
||||
"Alt",
|
||||
"Meta",
|
||||
" ",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Enter",
|
||||
"Backspace",
|
||||
"Escape"
|
||||
];
|
||||
|
||||
function useText(elementId, parentId, left)
|
||||
{
|
||||
let el;
|
||||
let parent;
|
||||
|
||||
const show = Vue.ref(false);
|
||||
const leftSide = Vue.ref(false);
|
||||
|
||||
const calcSide = () => {
|
||||
parent.clientWidth - left.value < inputLength ? leftSide.value = true : leftSide.value = false;
|
||||
}
|
||||
|
||||
/* handle user typing */
|
||||
Vue.onMounted(() => {
|
||||
|
||||
el = document.getElementById(elementId);
|
||||
parent = document.getElementById(parentId);
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
|
||||
if (!show.value && !metaKeys.includes(e.key) && !e.ctrlKey)
|
||||
{
|
||||
show.value = true;
|
||||
}
|
||||
else if (show.value && ((el.value.length === 1 && e.key === "Backspace") || e.key === "Escape"))
|
||||
{
|
||||
show.value = false;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && el.value)
|
||||
{
|
||||
if (el.value.length >= 250) {
|
||||
alert("250 character limit")
|
||||
return;
|
||||
}
|
||||
|
||||
window.mainApi.send("message", {
|
||||
category: "text",
|
||||
text: el.value,
|
||||
blob: null
|
||||
});
|
||||
|
||||
show.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
calcSide();
|
||||
|
||||
});
|
||||
|
||||
Vue.watch(left, calcSide);
|
||||
|
||||
Vue.watch(show, (c, _p) => {
|
||||
if (c) {
|
||||
el.focus();
|
||||
} else {
|
||||
el.value = "";
|
||||
el.blur();
|
||||
}
|
||||
});
|
||||
|
||||
return { leftSide, show };
|
||||
}
|
||||
|
||||
export default useText;
|
||||
23
src/render/components/control/valid.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/render/components/form.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
|
||||
// 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;
|
||||
|
||||
|
||||
|
||||
23
src/render/components/header.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
const Header =
|
||||
{
|
||||
setup() {
|
||||
const onNavBar = (command) => window.mainApi.send("nav", command);
|
||||
return { onNavBar };
|
||||
},
|
||||
|
||||
template: `
|
||||
<button id="header-titlebar" />
|
||||
|
||||
<span id="header-menu">
|
||||
<button
|
||||
class="header-menu-button header-exit-button button"
|
||||
@click.prevent="onNavBar('close')"
|
||||
/>
|
||||
<button
|
||||
class="header-menu-button header-min-button button"
|
||||
@click.prevent="onNavBar('min')"
|
||||
/>
|
||||
</span> `
|
||||
}
|
||||
|
||||
export default Header;
|
||||
55
src/render/components/input.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import useText from "./control/text.js";
|
||||
import draggify from "./control/draggify.js";
|
||||
|
||||
const Input =
|
||||
{
|
||||
props: ["person"],
|
||||
|
||||
setup(props)
|
||||
{
|
||||
const initials = Vue.ref("");
|
||||
|
||||
const recording = Vue.ref(false);
|
||||
|
||||
const { elementX, elementY } = draggify("input", "app", 15);
|
||||
|
||||
const { leftSide, show } = useText("input-text", "app", elementX);
|
||||
|
||||
window.mainApi.on("record", (status) => recording.value = status);
|
||||
|
||||
Vue.watch(() => props.person, (c, _p) => initials.value = getInitials(c));
|
||||
|
||||
return { initials, elementX, elementY, recording, leftSide, show };
|
||||
},
|
||||
|
||||
template: `
|
||||
<div id="input" :class="{ audio: recording }"
|
||||
:style="{ top: elementY + 'px', left: elementX + 'px' }"
|
||||
>
|
||||
|
||||
<div id="input-icon">{{ initials }}</div>
|
||||
|
||||
<input
|
||||
id="input-text" type="text"
|
||||
:class="[
|
||||
{ leftSide: leftSide },
|
||||
{ show: show },
|
||||
{ recording: recording }
|
||||
]"
|
||||
/>
|
||||
|
||||
</div> `
|
||||
}
|
||||
|
||||
function getInitials(name)
|
||||
{
|
||||
let rgx = new RegExp(/(\p{L}{1})\p{L}+/, 'gu');
|
||||
|
||||
let initials = [...name.matchAll(rgx)] || [];
|
||||
|
||||
return (
|
||||
(initials.shift()?.[1] || '') + (initials.pop()?.[1] || '')
|
||||
).toUpperCase();
|
||||
}
|
||||
|
||||
export default Input;
|
||||
44
src/render/components/login.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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;
|
||||
62
src/render/components/message.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import Bubble from "./bubble.js";
|
||||
import Context from "./context.js";
|
||||
|
||||
const Message =
|
||||
{
|
||||
components: {
|
||||
Bubble,
|
||||
Context
|
||||
},
|
||||
|
||||
props: ["modifier", "content", "context", "avatar", "id", "seen"],
|
||||
|
||||
setup(props)
|
||||
{
|
||||
return { calcChild };
|
||||
},
|
||||
|
||||
template: `
|
||||
<div :id="id" :class="'message ' + modifier + '-message'">
|
||||
|
||||
<Bubble
|
||||
v-for="(c, index) in content"
|
||||
:id="'bubble-' + id + '-' + index"
|
||||
:category="c.category"
|
||||
:text="c.text"
|
||||
:html="c.html"
|
||||
:blob="c.blob"
|
||||
:modifier="modifier"
|
||||
:child="calcChild(index, content.length)"
|
||||
/>
|
||||
|
||||
<Context
|
||||
:modifier="modifier"
|
||||
:context="context"
|
||||
:avatar="avatar"
|
||||
:id="id"
|
||||
/>
|
||||
|
||||
</div> `
|
||||
}
|
||||
|
||||
function calcChild(index, len)
|
||||
{
|
||||
if (len === 1)
|
||||
{
|
||||
return "none-child";
|
||||
}
|
||||
else if (index === 0)
|
||||
{
|
||||
return "first-child";
|
||||
}
|
||||
else if (index === len - 1)
|
||||
{
|
||||
return "last-child";
|
||||
}
|
||||
else
|
||||
{
|
||||
return "middle-child";
|
||||
}
|
||||
}
|
||||
|
||||
export default Message;
|
||||
86
src/render/components/messenger.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import onDrop from "./control/drop.js";
|
||||
import scroll from "./control/scroll.js";
|
||||
|
||||
import WS from "./ws.js";
|
||||
import Input from "./input.js";
|
||||
import Settings from "./settings.js";
|
||||
import Message from "./message.js";
|
||||
|
||||
const Messenger = {
|
||||
|
||||
components: {
|
||||
WS,
|
||||
Settings,
|
||||
Message,
|
||||
Input
|
||||
},
|
||||
|
||||
setup()
|
||||
{
|
||||
const person = Vue.ref("");
|
||||
const messages = Vue.ref([]);
|
||||
|
||||
let current = Vue.ref();
|
||||
|
||||
Vue.onMounted(() => {
|
||||
|
||||
window.mainApi.on("message", (update) => {
|
||||
|
||||
if (update.person)
|
||||
{
|
||||
person.value = update.person;
|
||||
messages.value = update.messages;
|
||||
current.value = messages.value.at(-1);
|
||||
}
|
||||
else if (update.name)
|
||||
{
|
||||
person.value = update.name;
|
||||
}
|
||||
else if (update.modifier)
|
||||
{
|
||||
messages.value.push(update);
|
||||
current.value = update;
|
||||
}
|
||||
else if (update.category)
|
||||
{
|
||||
current.value.content.push(update);
|
||||
}
|
||||
else if (update.context)
|
||||
{
|
||||
current.value.context = update.context;
|
||||
}
|
||||
else
|
||||
{
|
||||
current.value.content.at(-1).text = update.text;
|
||||
}
|
||||
|
||||
setTimeout(scroll, 10);
|
||||
|
||||
});
|
||||
|
||||
window.mainApi.send("messenger");
|
||||
});
|
||||
|
||||
return { person, messages, onDrop };
|
||||
},
|
||||
|
||||
template: `
|
||||
<Settings />
|
||||
<WS />
|
||||
<Input :person="person" />
|
||||
|
||||
<div id="messenger"
|
||||
@drop="onDrop($event)"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent
|
||||
>
|
||||
|
||||
<Message
|
||||
v-for="message in messages"
|
||||
v-bind="message"
|
||||
/>
|
||||
|
||||
</div>`
|
||||
}
|
||||
|
||||
export default Messenger;
|
||||
40
src/render/components/settings.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
|
||||
const Settings =
|
||||
{
|
||||
setup() {
|
||||
const active = Vue.ref(false);
|
||||
|
||||
function hideSettings(e) {
|
||||
if (e.key == "Escape") {
|
||||
active.value = false;
|
||||
window.removeEventListener("keydown", hideSettings);
|
||||
}
|
||||
}
|
||||
|
||||
function showSettings() {
|
||||
active.value = true;
|
||||
window.addEventListener("keydown", hideSettings);
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await window.mainApi.send("logout");
|
||||
}
|
||||
|
||||
return { showSettings, active, logout, window };
|
||||
},
|
||||
|
||||
template: `
|
||||
<div v-if="active" id="settings">
|
||||
|
||||
<button class="settings-logout-button button" @click="logout">
|
||||
Logout
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<button v-else class="settings-icon" @click="showSettings">
|
||||
<img :src="window.path + 'assets/settingsIcon.svg'">
|
||||
</button>`
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
50
src/render/components/splash.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
const Splash =
|
||||
{
|
||||
template: `
|
||||
<div id="splash">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="57.046"
|
||||
height="52.759"
|
||||
viewBox="0 0 57.046 52.759"
|
||||
>
|
||||
<g transform="translate(-4127 -507)">
|
||||
<g transform="translate(3949 332.206)">
|
||||
<g transform="translate(178 174.794)">
|
||||
<g transform="translate(0 27.405)">
|
||||
<circle
|
||||
cx="12.677"
|
||||
cy="12.677"
|
||||
r="12.677"
|
||||
transform="translate(0 0)"
|
||||
fill="#383838"
|
||||
/>
|
||||
<circle
|
||||
cx="12.677"
|
||||
cy="12.677"
|
||||
r="12.677"
|
||||
transform="translate(31.692 0)"
|
||||
fill="#383838"
|
||||
/>
|
||||
</g>
|
||||
<circle
|
||||
cx="12.677"
|
||||
cy="12.677"
|
||||
r="12.677"
|
||||
transform="translate(15.822 0)"
|
||||
fill="#383838"
|
||||
/>
|
||||
<path
|
||||
d="M36.27,83.67"
|
||||
transform="translate(-23.569 -43.588)"
|
||||
fill="#ff0"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export default Splash;
|
||||
35
src/render/components/ws.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
|
||||
const WS =
|
||||
{
|
||||
setup()
|
||||
{
|
||||
const status = Vue.ref();
|
||||
|
||||
Vue.onMounted(() => {
|
||||
|
||||
window.mainApi.on("ws", (val) => {
|
||||
status.value = val;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
return { status, window }
|
||||
},
|
||||
|
||||
template: `
|
||||
<div id="ws">
|
||||
|
||||
<img id="ws-logo" :src="window.path + 'assets/connectLogo.svg'"
|
||||
:class="{ move: status }"
|
||||
>
|
||||
|
||||
<div id="ws-dots"
|
||||
:class="{ move: status }"
|
||||
>
|
||||
<div class="ws-dot"></div>
|
||||
</div>
|
||||
|
||||
</div> `
|
||||
}
|
||||
|
||||
export default WS;
|
||||
17
src/render/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="./main.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- vue@3.2.11 -->
|
||||
<script src="./vendor/vue.js"></script>
|
||||
|
||||
<script type="module" src="./index.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
5
src/render/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import App from "./components/app.js";
|
||||
|
||||
window.path = "./";
|
||||
|
||||
Vue.createApp(App).mount("#app");
|
||||
582
src/render/main.css
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
|
||||
@font-face {
|
||||
font-family: "Default";
|
||||
src: url("assets/fonts/SF-Pro-Text-Regular.otf");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Compact";
|
||||
src: url("assets/fonts/SF-Compact-Display-Bold.otf");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Rounded";
|
||||
src: url("assets/fonts/SF-Compact-Rounded-Bold.otf");
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/*background-color: rgba(235, 235, 235, 0.75);*/
|
||||
}
|
||||
|
||||
/* The first word of the class is the component that it modifys. */
|
||||
|
||||
#splash {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
position: relative; /* must explicitly be declared */
|
||||
font-family: "Default";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100vh; /* 100% for website */
|
||||
width: 100vw; /* 100% for website */
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
#header-titlebar {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
opacity: 0.75;
|
||||
background-color: #EBEBEB;
|
||||
-webkit-app-region: drag;
|
||||
border: none;
|
||||
outline: none;
|
||||
z-index: 1;
|
||||
border-top-left-radius: 15px;
|
||||
border-top-right-radius: 15px;
|
||||
}
|
||||
|
||||
.contacts {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: inherit;
|
||||
background-color: #DBDBDB;
|
||||
}
|
||||
|
||||
.contacts > li {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.contacts img {
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.contacts .info {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.contacts .name {
|
||||
|
||||
}
|
||||
|
||||
.contacts .email {
|
||||
font-family: "Compact";
|
||||
font-size: 12px;
|
||||
color: #898989;
|
||||
overflow: w;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#header-menu {
|
||||
position: absolute;
|
||||
margin-left: 20px;
|
||||
margin-top: 20px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.header-menu-button {
|
||||
min-width: 14px;
|
||||
min-height: 14px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-exit-button {
|
||||
background-color: #FF6157;
|
||||
}
|
||||
|
||||
.header-exit-button:active {
|
||||
background: #c14645;
|
||||
}
|
||||
|
||||
.header-min-button {
|
||||
background-color: #FFC12F;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.header-min-button:active {
|
||||
background-color: #c08e38;
|
||||
}
|
||||
|
||||
#login {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#messenger {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* hide native scrollbar */
|
||||
#messenger::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#ws {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#ws-logo {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
#ws-logo.move {
|
||||
transform: translateX(15px);
|
||||
}
|
||||
|
||||
#ws-dots {
|
||||
opacity: 0;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
#ws-dots.move {
|
||||
opacity: 1;
|
||||
transform: translateX(-15px);
|
||||
}
|
||||
|
||||
.ws-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #48E065;
|
||||
position: relative;
|
||||
transform: translateX(-15px);
|
||||
animation: ws-dot-flashing 1s infinite linear alternate;
|
||||
animation-delay: .25s;
|
||||
}
|
||||
|
||||
.ws-dot::before, .ws-dot::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.ws-dot::before {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #48E065;
|
||||
left: -9px;
|
||||
animation: ws-dot-flashing 1s infinite alternate;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.ws-dot::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #48E065;
|
||||
left: 9px;
|
||||
animation: ws-dot-flashing 1s infinite alternate;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
@keyframes ws-dot-flashing {
|
||||
0% {
|
||||
background-color: #48E065;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
background-color: #9B9B9B;
|
||||
}
|
||||
}
|
||||
|
||||
#input {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
z-index: 3;
|
||||
/* To prevent window drag when overlapping with titlebar. */
|
||||
-webkit-app-region: no-drag;
|
||||
border-radius: 50%;
|
||||
/* Set opacity here to not affect child. */
|
||||
background-color: rgba(235, 235, 235, 0.75);
|
||||
cursor: pointer;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
#input-text {
|
||||
position: absolute;
|
||||
min-width: 150px;
|
||||
height: 16px;
|
||||
border-radius: 18px;
|
||||
padding: 10px;
|
||||
outline: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
background-color: white;
|
||||
z-index: -1;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
|
||||
transform-origin: center;
|
||||
opacity: 0;
|
||||
transform: translateX(70%);
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
#input-text.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#input-text.leftSide {
|
||||
transform: translateX(-70%);
|
||||
}
|
||||
|
||||
#settings {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(235, 235, 235, 0.75);
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
animation-name: settings-appear;
|
||||
animation-duration: 0.5s;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@keyframes settings-appear {
|
||||
from {
|
||||
background-color: rgba(235, 235, 235, 0);
|
||||
}
|
||||
to {
|
||||
background-color: rgba(235, 235, 235, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 5px;
|
||||
margin-right: 20px;
|
||||
margin-top: 19px;
|
||||
z-index: 2;
|
||||
background-color: Transparent;
|
||||
}
|
||||
|
||||
.settings-icon:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-account {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.settings-logout-button {
|
||||
font-family: "Compact";
|
||||
background-color: #B7B7B7;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-logout-button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-version {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 75%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #575757;
|
||||
}
|
||||
|
||||
.message {
|
||||
width: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
animation-name: message-init-anim;
|
||||
animation-duration: 0.25s;
|
||||
}
|
||||
|
||||
@keyframes message-init-anim {
|
||||
from {
|
||||
opacity: 0;
|
||||
} to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.message:first-child {
|
||||
margin-top: 55px;
|
||||
}
|
||||
|
||||
.message:last-child {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-session {
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: "Compact";
|
||||
font-size: 12px;
|
||||
color: #9B9B9B;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.client-message {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.ai-message {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-message {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
max-width: 66%;
|
||||
font-size: 14px;
|
||||
border-radius: 18px;
|
||||
margin-bottom: 4px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.bubble-notify {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: #58D9FF;
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid #EBEBEB;
|
||||
transform: scale(0);
|
||||
animation-name: bubble-notify-anim;
|
||||
animation-duration: 5s;
|
||||
}
|
||||
|
||||
@keyframes bubble-notify-anim {
|
||||
0%, 90% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-bubble {
|
||||
background-color: #FFFFFF;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.client-bubble {
|
||||
color: white;
|
||||
background-color: #58C4FD;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.ai-first-child {
|
||||
border-bottom-left-radius: 9px;
|
||||
}
|
||||
|
||||
.ai-middle-child {
|
||||
border-top-left-radius: 9px;
|
||||
border-bottom-left-radius: 9px;
|
||||
}
|
||||
|
||||
.ai-last-child {
|
||||
border-top-left-radius: 9px;
|
||||
}
|
||||
|
||||
.client-first-child {
|
||||
border-bottom-right-radius: 9px;
|
||||
}
|
||||
|
||||
.client-middle-child {
|
||||
border-top-right-radius: 9px;
|
||||
border-bottom-right-radius: 9px;
|
||||
}
|
||||
|
||||
.client-last-child {
|
||||
border-top-right-radius: 9px;
|
||||
}
|
||||
|
||||
.bubble > p {
|
||||
margin: 0px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.bubble > div {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.bubble > img {
|
||||
border-radius: inherit;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bubble a {
|
||||
display: inline-block;
|
||||
font-family: "Rounded";
|
||||
text-decoration: none;
|
||||
border-radius: inherit;
|
||||
background-color: #D9D9D9;
|
||||
padding: 10px;
|
||||
color: #727272;
|
||||
}
|
||||
|
||||
.context {
|
||||
position: relative;
|
||||
min-height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: "Compact";
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.ai-context {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.client-context {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.context-avatar {
|
||||
margin-right: 5px;
|
||||
border-radius: 50%;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
/* ---------- shared between components ---------- */
|
||||
|
||||
.button {
|
||||
border: none;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/*.button:hover {
|
||||
cursor: pointer;
|
||||
}*/
|
||||
|
||||
/* shake a div to signal error*/
|
||||
.shake {
|
||||
animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
10%, 90% {
|
||||
transform: translate3d(-1px, 0, 0);
|
||||
}
|
||||
|
||||
20%, 80% {
|
||||
transform: translate3d(2px, 0, 0);
|
||||
}
|
||||
|
||||
30%, 50%, 70% {
|
||||
transform: translate3d(-4px, 0, 0);
|
||||
}
|
||||
|
||||
40%, 60% {
|
||||
transform: translate3d(4px, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.audio {
|
||||
animation-name: audio-anim;
|
||||
animation-duration: 2s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes audio-anim {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0.4em rgba(195, 195, 195, 0.4), 0 0 0 0.25em rgba(195, 195, 195, 0.15);
|
||||
}
|
||||
25% {
|
||||
box-shadow: 0 0 0 0.15em rgba(195, 195, 195, 0.15), 0 0 0 0.4em rgba(195, 195, 195, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 0.55em rgba(195, 195, 195, 0.55), 0 0 0 0.15em rgba(195, 195, 195, 0.05);
|
||||
}
|
||||
75% {
|
||||
box-shadow: 0 0 0 0.25em rgba(195, 195, 195, 0.25), 0 0 0 0.55em rgba(195, 195, 195, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.smart-form {
|
||||
width: 225px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.text-field {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 4px;
|
||||
font-size: 14px;
|
||||
border-radius: 10px;
|
||||
background-color: #d1d1d1;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.smart-form-logs {
|
||||
font-family: "Compact";
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
margin-left: 10px;
|
||||
}
|
||||
53
src/render/preload.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
/** The main <-> render interface */
|
||||
|
||||
const validFromMainChannels = [
|
||||
"playback",
|
||||
"account",
|
||||
"message",
|
||||
"record",
|
||||
"ws"
|
||||
];
|
||||
|
||||
const validToMain = [
|
||||
"messenger",
|
||||
"message",
|
||||
"logout",
|
||||
"encode",
|
||||
"auth",
|
||||
"nav"
|
||||
]
|
||||
|
||||
ipcRenderer.setMaxListeners(250);
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
// TODO: Implement argument filtering for added security
|
||||
contextBridge.exposeInMainWorld(
|
||||
"mainApi", {
|
||||
invoke: async (channel, ...args) => {
|
||||
if (validToMain.includes(channel)) {
|
||||
try {
|
||||
const res = await ipcRenderer.invoke(channel, ...args);
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.log("IPCRenderer.invoke error");
|
||||
}
|
||||
}
|
||||
},
|
||||
send: (channel, ...args) => {
|
||||
if (validToMain.includes(channel)) {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
}
|
||||
},
|
||||
on: (channel, func) => {
|
||||
if (validFromMainChannels.includes(channel)) {
|
||||
ipcRenderer.on(channel, (event, ...args) => func(...args));
|
||||
}
|
||||
},
|
||||
removeAllListeners: (channel) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
}
|
||||
}
|
||||
);
|
||||
15872
src/render/vendor/vue.js
vendored
Normal file
105
src/session.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
const { Notification, ipcMain } = require("electron");
|
||||
|
||||
const store = require("./utils/store");
|
||||
const { backgroundMitt, ipcEmit } = require("./utils/emitter");
|
||||
const { connectToPlatform, disconnectFromPlatform } = require("./io");
|
||||
const { initAudio, terminateAudio, playback, streamState } = require("./audio");
|
||||
|
||||
const util = require('util');
|
||||
|
||||
let ui;
|
||||
let current;
|
||||
|
||||
const messageQueue = [];
|
||||
|
||||
let processContentId;
|
||||
|
||||
/* Possible messages:
|
||||
* - New UI (set ui)
|
||||
* - New message (add message to ui.messages)
|
||||
* - New content (add content to existing message)
|
||||
* - Update person
|
||||
* - Notification
|
||||
*/
|
||||
backgroundMitt.on("message", (message) => messageQueue.push(message));
|
||||
|
||||
function handleMessageQueue()
|
||||
{
|
||||
if (!streamState.pb && !streamState.rec)
|
||||
{
|
||||
const update = messageQueue.shift();
|
||||
|
||||
if (update)
|
||||
{
|
||||
if (update.person)
|
||||
{
|
||||
ui = update;
|
||||
current = update.messages.at(-1);
|
||||
}
|
||||
else if (update.name)
|
||||
{
|
||||
ui.person = update;
|
||||
}
|
||||
else if (update.modifier)
|
||||
{
|
||||
ui.messages.push(update);
|
||||
current = update;
|
||||
}
|
||||
else if (update.category)
|
||||
{
|
||||
current.content.push(update);
|
||||
}
|
||||
else if (update.context)
|
||||
{
|
||||
current.context = update.context;
|
||||
}
|
||||
else if (update.text)
|
||||
{
|
||||
current.content.at(-1).text = update.text;
|
||||
}
|
||||
else
|
||||
{
|
||||
const content = current.content.at(-1);
|
||||
|
||||
new Notification({
|
||||
title: current.context,
|
||||
body: content.text
|
||||
}).show();
|
||||
|
||||
/* Ideally, we want a more native solution than this */
|
||||
if (current.modifier == "ai" && content.category == "audio")
|
||||
{
|
||||
playback(content.blob, current.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ipcEmit("message", update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function launchSession(account)
|
||||
{
|
||||
connectToPlatform(account);
|
||||
initAudio();
|
||||
processContentId = setInterval(handleMessageQueue, 100);
|
||||
}
|
||||
|
||||
function endSession()
|
||||
{
|
||||
clearInterval(processContentId);
|
||||
terminateAudio();
|
||||
disconnectFromPlatform();
|
||||
ui = null;
|
||||
}
|
||||
|
||||
ipcMain.on("messenger", () => {
|
||||
if (ui) {
|
||||
ipcEmit("message", ui);
|
||||
}
|
||||
});
|
||||
|
||||
exports.launchSession = launchSession;
|
||||
exports.endSession = endSession;
|
||||
43
src/tray.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Tray } = require("electron");
|
||||
|
||||
const { backgroundMitt } = require("./utils/emitter");
|
||||
|
||||
let tray;
|
||||
const TRAY_PATH = path.join(__dirname, "assets/tray");
|
||||
|
||||
/* Current state of recording, playback, and ws connection */
|
||||
const state = {
|
||||
recording: false,
|
||||
playback: false,
|
||||
disconnect: 0 // whether we're experiencing connection issues
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the tray icon given a new state of an attribute.
|
||||
* @param {"recording" | "playback" | "disconnect"} attribute
|
||||
* @param {boolean} newState
|
||||
*/
|
||||
function updateTray(attribute, newState)
|
||||
{
|
||||
state[attribute] = newState;
|
||||
|
||||
// convert boolean to number
|
||||
const bi = (b) => b ? 1 : 0;
|
||||
|
||||
// Make sure we have the icon we're looking for.
|
||||
tray.setImage(path.join(
|
||||
TRAY_PATH,
|
||||
`${bi(state.recording)}${bi(state.playback)}${bi(state.disconnect)}` +
|
||||
".png"
|
||||
));
|
||||
}
|
||||
|
||||
function initTray()
|
||||
{
|
||||
tray = new Tray(path.join(TRAY_PATH, "000.png"));
|
||||
}
|
||||
|
||||
exports.initTray = initTray;
|
||||
exports.updateTray = updateTray;
|
||||
11
src/utils/emitter.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const EventEmitter = require("events");
|
||||
|
||||
const backgroundMitt = new EventEmitter();
|
||||
|
||||
const ipcEmit = (channel, ...args) =>
|
||||
{
|
||||
backgroundMitt.emit("ipc-renderer", channel, ...args);
|
||||
};
|
||||
|
||||
module.exports.backgroundMitt = backgroundMitt;
|
||||
module.exports.ipcEmit = ipcEmit;
|
||||
5
src/utils/store.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const Store = require("electron-store");
|
||||
|
||||
module.exports = new Store({
|
||||
encryptionKey: "super user test"
|
||||
});
|
||||
80
src/window.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
const path = require("path");
|
||||
const { app, BrowserWindow, ipcMain } = require("electron");
|
||||
|
||||
const store = require("./utils/store");
|
||||
const { sendMessage } = require("./io");
|
||||
const { backgroundMitt } = require("./utils/emitter");
|
||||
|
||||
let win;
|
||||
|
||||
function updateWindowPosition()
|
||||
{
|
||||
const bounds = win.getBounds();
|
||||
const position = win.getPosition();
|
||||
|
||||
store.set("window-position", {
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: position[0],
|
||||
y: position[1]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function onNav(_e, cmd)
|
||||
{
|
||||
cmd === "close" ? win.close() : win.minimize();
|
||||
}
|
||||
|
||||
function createWin()
|
||||
{
|
||||
if (win) return;
|
||||
|
||||
let pos = store.get("window-position");
|
||||
if (!pos) pos = { width: 350, height: 750, x: null, y: null }
|
||||
|
||||
win = new BrowserWindow({
|
||||
width: pos.width,
|
||||
height: pos.height,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
resizable: true,
|
||||
frame: false,
|
||||
minWidth: 350,
|
||||
minHeight: 500,
|
||||
backgroundColor: "#EBEBEB",
|
||||
webPreferences: { preload: path.join(__dirname, "render/preload.js") }
|
||||
})
|
||||
|
||||
// TODO: find a simpler way to notify platform of user events
|
||||
.on("focus", () => store.set("focus", true))
|
||||
.on("blur", () => store.set("focus", false))
|
||||
|
||||
.on("closed", () => {
|
||||
store.set("focus", false);
|
||||
win = null;
|
||||
})
|
||||
|
||||
.on("moved", updateWindowPosition)
|
||||
.on("resize", updateWindowPosition);
|
||||
|
||||
win.loadFile(path.join(__dirname, "render/index.html"));
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
|
||||
backgroundMitt.removeAllListeners("ipc-renderer");
|
||||
|
||||
backgroundMitt.on("ipc-renderer", (channel, ...args) => {
|
||||
if (win)
|
||||
{
|
||||
win.webContents.send(channel, ...args);
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.send("account", store.get("account"));
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.on("nav", onNav);
|
||||
|
||||
exports.createWin = createWin;
|
||||