TypeScript + Vue 3 + Elec 11 continuation

This commit is contained in:
Aziz Hasanain 2021-04-03 17:57:19 +03:00
parent 58fa723720
commit 1ca699727b
13 changed files with 1313 additions and 1022 deletions

View File

@ -1,952 +1,14 @@
<template>
<!-- <vue-confirm-dialog class="confirmDialog"></vue-confirm-dialog> -->
<settings ref="settingsModal" @saved="connectWS"></settings>
<div id="nav" :class="{ notrans: !$store.state.acceleration }">
<div class="titlebar">
<div class="buttons" v-if="$store.state.macstyle || process.platform === 'darwin'">
<div class="close" @click="closeWindow">
<span class="closebutton"><span>x</span></span>
</div>
<div class="minimize" @click="minimizeWindow">
<span class="minimizebutton"><span>&ndash;</span></span>
</div>
<div class="zoom" @click="maximizeWindow">
<span class="zoombutton"><span>+</span></span>
</div>
</div>
<div class="statusIndicator" style="margin-top: 1px;" v-tooltip:bottom.tooltip="statusText">
<feather type="circle" stroke="rgba(25,25,25,0.5)" :fill="statusColor" size="10"></feather>
</div>
<div class="menuBtn">
<feather type="settings" stroke="rgba(152,152,152,0.5)" size="20" @click="$refs.settingsModal.openModal()"></feather>
</div>
<div class="menuBtn">
<feather type="refresh-cw" stroke="rgba(152,152,152,0.5)" size="19" @click="connectWS"></feather>
</div>
<div class="menuBtn">
<feather type="edit" stroke="rgba(36,132,255,0.65)" size="20" @click="composeMessage"></feather>
</div>
<div class="menuBtn" v-if="updateAvailable">
<feather type="download" stroke="rgba(152,255,152,0.65)" size="20" @click="restart"></feather>
</div>
</div>
<div class="searchContainer">
<input type="search" placeholder="Search" class="textinput" v-model="search" />
</div>
<div class="chats scrollable" ref="chats">
<chat
v-for="chat in filteredChats"
:key="chat.id"
:chatid="chat.personId"
:author="chat.author"
:text="chat.text"
:date="chat.date"
:read="chat.read"
:docid="chat.docid"
:showNum="chat.showNum"
:isGroup="chat.personId.startsWith('chat') && !chat.personId.includes('@') && chat.personId.length >= 20"
@deleted="deleteChat(chat)"
/>
</div>
</div>
<div id="content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" @markAsRead="markAsRead" />
</transition>
</router-view>
</div>
<home />
</template>
<script>
import Chat from '@/components/Chat.vue'
import Settings from '@/components/Settings.vue'
import { ipcRenderer, remote } from 'electron'
import socketMixin from './mixin/socket'
import Tooltip from '@/components/Tooltip.vue'
import Home from '@/views/Home'
export default {
name: 'App',
components: {
Chat,
Settings,
Tooltip,
},
data: () => {
return {
chats: [],
limit: 50,
offset: 0,
loading: false,
notifSound: null,
updateAvailable: false,
search: '',
process: window.process,
maximized: false,
maximizing: false,
win: null,
status: 0, // 0 for disconnected, 1 for connecting, 2 for connected,
lastNotificationGUID: '',
}
},
mixins: [socketMixin],
computed: {
filteredChats() {
let chats = this.chats.slice()
if (chats.length > 0) {
chats = chats.reduce((r, chat) => {
chat.showNum = false
const duplicateIndex = chats.findIndex(obj => obj.author == chat.author && obj.address != chat.address)
if (duplicateIndex > -1 && !chat.address.startsWith('chat') && (chat.address.startsWith('+') || chat.address.includes('@'))) {
chat.showNum = true
}
r.push(chat)
return r
}, [])
}
return chats
.filter(chat => {
return (
chat.author.toLowerCase().includes(this.search.toLowerCase()) || chat.text.toLowerCase().includes(this.search.toLowerCase())
)
})
.sort((a, b) => (b.date - a.date > 0 ? 1 : -1))
},
statusColor() {
if (this.status == 0) {
return 'rgba(255,0,0,0.8)'
} else if (this.status == 1) {
return 'rgba(255,100,0,0.8)'
} else if (this.status == 2) {
return 'rgba(0,255,0,0.5)'
}
return ''
},
statusText() {
if (this.status == 0) {
return 'Device not found'
} else if (this.status == 1) {
return 'Device found. Retrieving data...'
} else if (this.status == 2) {
return 'Device connected'
}
return ''
},
},
methods: {
markAsRead(val) {
const chatIndex = this.chats.findIndex(obj => obj.personId == val)
if (chatIndex > -1) {
const chat = this.chats[chatIndex]
if (!chat.read) {
if (document.hasFocus()) {
chat.read = true
this.sendSocket({
action: 'markAsRead',
data: { chatId: this.$route.params.id },
})
} else {
const onFocusHandler = () => {
if (!chat.read && this.$route.path == '/chat/' + val) {
chat.read = true
this.sendSocket({
action: 'markAsRead',
data: { chatId: this.$route.params.id },
})
}
window.removeEventListener('focus', onFocusHandler)
}
window.addEventListener('focus', onFocusHandler)
}
}
}
},
closeWindow() {
this.win.close()
},
minimizeWindow() {
this.win.minimize()
},
maximizeWindow() {
this.maximizing = true
if (this.maximized) {
this.win.restore()
this.win.setSize(700, 600)
this.win.center()
if (process.platform !== 'darwin') document.body.style.borderRadius = null
} else {
this.win.maximize()
if (process.platform !== 'darwin') document.body.style.borderRadius = '0'
}
this.maximized = !this.maximized
setTimeout(() => {
this.maximizing = false
}, 50)
},
restart() {
ipcRenderer.send('restart_app')
},
requestChats() {
if (this.$socket && this.$socket.readyState == 1) {
if (this.loading) return
this.loading = true
this.sendSocket({
action: 'fetchChats',
data: { offset: `${this.offset}`, limit: `${this.limit}` },
})
} else {
setTimeout(this.requestChats, 100)
}
},
connectWS() {
this.offset = 0
this.loading = false
this.$disconnect()
this.status = 0
this.chats = []
this.$store.commit('resetMessages')
this.$router.push('/').catch(() => {})
this.notifSound = new Audio(this.$store.state.notifSound)
const baseURI = this.$store.getters.baseURI
this.$connect(baseURI, {
format: 'json',
reconnection: true,
})
},
deleteChat(chat) {
const chatIndex = this.chats.findIndex(obj => obj.personId == chat.personId)
if (chatIndex > -1) {
this.chats.splice(chatIndex, 1)
}
if (this.$route.path == '/chat/' + chat.personId) {
this.$router.push('/')
}
this.$store.state.messagesCache[chat.personId] = null
},
composeMessage() {
if (this.status == 2) this.$router.push('/chat/new')
},
onMove(e) {
e.preventDefault()
if (this.maximizing) return
if (this.maximized) {
this.win.restore()
if (process.platform !== 'darwin') document.body.style.borderRadius = null
this.maximized = false
}
},
cacheMessages() {
if (!this.$store.state.cacheMessages) return
if (this.$socket && this.$socket.readyState == 1) {
for (let i = 0; i < this.chats.length; i++) {
const chat = this.chats[i]
this.sendSocket({
action: 'fetchMessages',
data: {
id: chat.personId,
offset: `0`,
limit: `25`,
},
})
}
} else {
setTimeout(this.cacheMessages, 1000)
}
},
sendNotifierNotification(options, messageData, chatData) {
const path = require('path')
const fs = require('fs')
const tempDir = path.join(__dirname, '..', 'temp')
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir)
}
const download = require('image-downloader')
const fileDir = path.join(tempDir, `avatar_${messageData.authorDocid}.jpg`)
const dlOptions = {
url: `${this.$store.getters.httpURI}/contactimg?docid=${messageData.authorDocid}&auth=${encodeURIComponent(
this.$store.state.password
)}`,
dest: fileDir,
agent: new require('https').Agent({ rejectUnauthorized: false }),
}
download
.image(dlOptions)
.then(() => {
options.icon = fileDir
if (fs.statSync(fileDir).size == 0) throw Error('Empty image')
})
.catch(() => {
options.icon = __static + '/icon.png'
})
.finally(() => {
if (!this.$store.state.systemSound) this.notifSound.play()
const NotificationCenter = require('node-notifier').NotificationCenter
let Notifier = null
if (process.platform === 'darwin') {
Notifier = new NotificationCenter({
withFallback: true,
customPath: path.join(
__dirname,
'../terminal-notifier/vendor/mac.noindex/terminal-notifier.app/Contents/MacOS/terminal-notifier'
),
})
} else {
Notifier = require('node-notifier')
}
Notifier.notify(options, (err, action) => {
if (err) return
if ((action == 'activate' || action == undefined) && chatData && chatData.id) {
ipcRenderer.send('show_win')
this.$router.push('/chat/' + messageData.personId)
}
})
})
},
},
beforeUnmount() {
$(window).off('resize')
this.win.removeListener('move', this.onMove)
ipcRenderer.removeAllListeners('win_id')
},
mounted() {
this.connectWS()
const container = this.$refs.chats
container.addEventListener('scroll', () => {
if (container.scrollTop + container.offsetHeight == container.scrollHeight && !this.loading) {
this.requestChats()
}
})
$(document).mousedown(event => {
if (event.which == 3) {
//this is a right click, so electron-context-menu will be appearing momentarily...
ipcRenderer.send('rightClickMessage', null)
}
})
ipcRenderer.send('loaded')
ipcRenderer.on('update_available', () => {
ipcRenderer.removeAllListeners('update_available')
})
ipcRenderer.on('update_downloaded', () => {
ipcRenderer.removeAllListeners('update_downloaded')
this.updateAvailable = true
})
ipcRenderer.on('navigateTo', (sender, id) => {
if (isNaN(id)) {
this.$router.push('/chat/new').catch(() => {})
} else {
const arrayId = parseInt(id) - 1
if (this.chats[arrayId]) {
this.$router.push('/chat/' + this.chats[arrayId].personId).catch(() => {})
}
}
})
ipcRenderer.on('win_id', (e, id) => {
this.win = remote.BrowserWindow.fromId(id)
window.addEventListener('resize', () => {
if (this.maximizing) return
if (this.maximized) {
this.win.restore()
if (process.platform !== 'darwin') document.body.style.borderRadius = null
this.maximized = false
}
})
this.win.on('move', this.onMove)
})
this.notifSound = new Audio(this.$store.state.notifSound)
if (!(this.$store.state.macstyle || process.platform === 'darwin')) {
document.body.style.border = 'none'
document.body.style.borderRadius = '0'
}
if (!this.$store.state.acceleration) {
document.documentElement.style.backgroundColor = 'black'
}
},
socket: {
fetchChats(data) {
if (this.offset == 0) this.chats = data
else this.chats.push(...data)
this.offset += this.limit
this.loading = false
this.cacheMessages()
this.status = 2
},
fetchMessages(data) {
if (data && data[0] && !this.$store.state.messagesCache[data[0].personId]) {
this.$store.commit('addMessages', { id: data[0].personId, data: data })
}
},
newMessage(data) {
const chatData = data.chat[0]
if (chatData && chatData.personId) {
const chatIndex = this.chats.findIndex(obj => obj.personId == chatData.personId)
if (chatIndex > -1) {
this.chats.splice(chatIndex, 1)
}
this.chats.unshift(chatData)
}
const messageData = data.message[0]
if (messageData) {
this.$store.commit('setTyping', {
chatId: messageData.personId,
isTyping: false,
})
if (this.$store.state.messagesCache[messageData.personId]) {
const oldMsgIndex = this.$store.state.messagesCache[messageData.personId].findIndex(obj => obj.guid == messageData.guid)
if (oldMsgIndex != -1) {
this.$store.state.messagesCache[messageData.personId][oldMsgIndex] = messageData
return
}
this.$store.state.messagesCache[messageData.personId].unshift(messageData)
}
if (messageData.sender != 1 && remote.Notification.isSupported()) {
if (this.$store.state.mutedChats.includes(messageData.personId)) return
if (this.lastNotificationGUID == messageData.guid) return
if (this.$route.params.id == messageData.personId && document.hasFocus()) return
let body = messageData.text.replace(/\u{fffc}/gu, '')
if (messageData.group && messageData.group.startsWith('chat')) {
body = `${messageData.author}: ${body}`
}
this.lastNotificationGUID = messageData.guid
const notificationOptions = {
appID: 'com.sgtaziz.WebMessage',
title: messageData.name,
message: body == '' ? 'Attachment' : body,
sound: this.$store.state.systemSound,
reply: true,
}
if (document.hasFocus()) return
this.sendNotifierNotification(notificationOptions, messageData, chatData)
} else if (messageData.sender != 1) {
console.log('Notifications are not supported on this system.')
}
}
},
newReaction(data) {
const reactions = data.reactions
if (reactions && reactions.length > 0 && this.$store.state.messagesCache[reactions[0].personId]) {
const msgIndex = this.$store.state.messagesCache[reactions[0].personId].findIndex(obj => obj.guid == reactions[0].forGUID)
if (msgIndex > -1) {
this.$store.state.messagesCache[reactions[0].personId][msgIndex]['reactions'] = reactions
}
}
const chatData = data.chat[0]
if (chatData && chatData.personId) {
const chatIndex = this.chats.findIndex(obj => obj.personId == chatData.personId)
if (chatIndex > -1) {
this.chats.splice(chatIndex, 1)
}
this.chats.unshift(chatData)
}
if (reactions && reactions.length > 0 && reactions[0].sender != 1 && remote.Notification.isSupported()) {
const reaction = reactions[0]
if (this.$store.state.mutedChats.includes(reaction.personId)) return
if (this.lastNotificationGUID == reaction.guid) return
if (this.$route.params.id == reaction.personId && document.hasFocus()) return
this.lastNotificationGUID = reaction.guid
const notificationOptions = {
appID: 'com.sgtaziz.WebMessage',
title: chatData.author,
message: reaction.text.replace(/\u{fffc}/gu, ''),
sound: this.$store.state.systemSound,
}
if (document.hasFocus()) return
this.sendNotifierNotification(notificationOptions, reaction, chatData)
} else if (reactions && reactions.length > 0 && reactions[0].sender != 1) {
console.log('Notifications are not supported on this system.')
}
},
setAsRead(data) {
if (this.$store.state.messagesCache[data.chatId]) {
const messageIndex = this.$store.state.messagesCache[data.chatId].findIndex(obj => obj.guid == data.guid)
if (messageIndex > -1) {
this.$store.state.messagesCache[data.chatId][messageIndex]['dateRead'] = data.read
this.$store.state.messagesCache[data.chatId][messageIndex]['dateDelivered'] = data.delivered
}
}
},
setTypingIndicator(data) {
if (data && data.chat_id) {
const chatId = data.chat_id
const typing = data.typing == true
if (chatId) this.$store.commit('setTyping', { chatId: chatId, isTyping: typing })
}
},
removeChat(data) {
if (data.chatId) {
const chatId = data.chatId
const chatIndex = this.chats.findIndex(obj => obj.address == chatId)
if (chatIndex > -1) {
const chat = this.chats[chatIndex]
if (this.$route.path == '/chat/' + chat.personId) {
this.$router.push('/')
}
this.$store.state.messagesCache[chat.personId] = null
this.chats.splice(chatIndex, 1)
}
}
},
onopen() {
this.offset = 0
this.requestChats()
this.status = 1
},
onerror() {
this.loading = false
if (this.$socket && this.$socket.readyState == 1) return
this.status = 1
this.$store.commit('resetMessages')
this.$router.push('/').catch(() => {})
this.chats = []
},
onclose() {
this.loading = false
if (this.$socket && this.$socket.readyState == 1) return
this.status = 0
this.$store.commit('resetMessages')
this.$router.push('/').catch(() => {})
this.chats = []
},
Home,
},
}
</script>
<style lang="scss">
.text-emoji {
width: 16px;
height: 16px;
margin-top: -2px;
margin-bottom: -3px;
// margin-right: 1px;
}
.confirmDialog {
.vc-container {
background-color: rgba(45, 45, 45, 0.9);
border: 1px solid rgba(25, 25, 25, 0.9);
.vc-text {
color: white;
font-weight: 300;
font-size: 14px;
}
.vc-title {
color: white;
font-weight: 500;
}
}
.vc-btn {
background-color: rgba(45, 45, 45, 0.9);
border-color: rgba(25, 25, 25, 0.9) !important;
color: rgba(255, 0, 0, 0.9);
&:hover {
background-color: rgba(45, 45, 45, 0.9);
filter: brightness(90%);
}
&.left {
color: #4083ff;
}
}
}
.vue-popover {
background: rgba(25, 25, 25, 0.8) !important;
font-size: 14px;
&::before {
border-bottom-color: rgba(25, 25, 25, 0.8) !important;
}
}
.emoji {
position: relative;
top: 0.15em;
width: 13px;
}
.searchContainer {
padding-right: 6px;
}
.menuBtn {
-webkit-app-region: no-drag;
width: auto;
float: right;
margin-right: 10px;
.feather {
cursor: pointer;
&:hover {
filter: brightness(80%);
}
}
}
.statusIndicator {
width: auto;
float: right;
margin-right: 10px;
-webkit-app-region: no-drag;
}
.textinput {
background-color: rgba(200, 200, 200, 0.1);
width: calc(100% - 4px);
margin: 0 !important;
margin-left: -2px !important;
border: 0px none;
border-color: rgba(87, 87, 87, 0.7) !important;
color: #ebecec !important;
text-align: left !important;
}
.chats {
margin-top: 12px;
max-height: calc(100% - 73px);
margin-right: 1px;
overflow-y: auto;
overflow-x: hidden;
.simplebar-scrollbar:before {
background: #575757;
}
}
.scrollable {
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: #424241;
border-radius: 16px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
@import url('https://fonts.googleapis.com/css?family=Roboto:light,regular,medium,thin,italic,mediumitalic,bold');
html {
height: 100%;
max-height: 100%;
width: 100%;
background-color: rgba(29, 29, 29, 0);
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, Avenir, Helvetica, Arial, sans-serif;
font-weight: 300;
}
body {
margin: 0;
height: calc(100% - 2px);
max-height: 100%;
width: calc(100% - 2px);
background-color: rgba(29, 29, 29, 0);
overflow: hidden;
border: 1px solid rgb(0, 0, 0);
border-radius: 10px;
}
#app {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, Avenir, Helvetica, Arial, sans-serif;
font-weight: 300;
text-align: center;
color: #ebecec;
position: absolute;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
background-color: rgba(29, 29, 29, 0);
border: 1px solid #4a4a4a;
border-radius: 10px;
&.maximized {
border-radius: 0;
#nav {
border-radius: 0;
}
#content {
border-radius: 0;
}
}
&.nostyle {
background-color: rgba(40, 40, 40, 1);
border: none;
border-radius: 0;
height: 100%;
width: 100%;
#nav {
border-radius: 0 !important;
margin-left: -1px;
margin-top: -1px;
width: 312px;
}
#content {
bottom: 0;
right: 0;
top: 0;
border-radius: 0;
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease, backdrop-filter 0.2s ease;
}
.fade-enter-from,
.fade-leave-active {
opacity: 0;
}
#nav {
background-color: rgba(40, 40, 40, 0.93);
width: 311px;
padding: 9px;
padding-right: 0;
float: left;
position: absolute;
top: 0;
left: 0;
bottom: 0;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
&.notrans {
background-color: rgba(40, 40, 40, 1);
}
a {
font-weight: bold;
color: #2c3e50;
&.router-link-exact-active {
color: #42b983;
}
}
input:not([type='range']):not([type='color']):not(.message-input) {
height: auto;
height: inherit;
font-size: 13px;
height: 6px;
padding: 10px 6px 10px 6px;
outline: none;
border: 1px solid rgb(213, 213, 213);
margin: 5px;
cursor: text;
-webkit-app-region: no-drag;
}
input:not([type='range']):not([type='color']):not(.message-input):focus {
border-radius: 1px;
box-shadow: 0px 0px 0px 3.5px rgba(23, 101, 144, 1);
animation: showFocus 0.3s;
border-color: rgb(122, 167, 221) !important;
}
}
.titlebar {
-webkit-app-region: drag;
width: calc(100% - 10px);
height: 30px;
padding: 5px;
padding-top: 7px;
}
.buttons {
-webkit-app-region: no-drag;
padding-left: 5px;
padding-top: 3px;
padding-right: 20px;
float: left;
line-height: 0px;
div:hover {
filter: brightness(75%);
}
}
.close {
background: #ff5c5c;
font-size: 9pt;
line-height: 12px;
width: 12px;
height: 12px;
border: 0px solid #e33e41;
border-radius: 50%;
display: inline-block;
}
.close:active {
background: #c14645;
}
.closebutton {
color: #820005;
visibility: hidden;
cursor: default;
}
.minimize {
background: #ffbd4c;
font-size: 9pt;
line-height: 12px;
margin-left: 8px;
width: 12px;
height: 12px;
border: 0px solid #e09e3e;
border-radius: 50%;
display: inline-block;
}
.minimize:active {
background: #c08e38;
}
.minimizebutton {
color: #9a5518;
visibility: hidden;
cursor: default;
}
.zoom {
background: #00ca56;
font-size: 9pt;
line-height: 12px;
margin-left: 8px;
width: 12px;
height: 12px;
border: 0px solid #14ae46;
border-radius: 50%;
display: inline-block;
}
.zoom:active {
background: #029740;
}
.zoombutton {
color: #006519;
visibility: hidden;
cursor: default;
}
#content {
float: left;
background-color: rgba(29, 29, 29, 1);
position: fixed;
top: 1px;
left: 321px;
right: 1px;
bottom: 1px;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
border-left: 1px solid #000;
overflow: hidden;
}
@keyframes showFocus {
0% {
border-color: #dbdbdb;
border-radius: -1px;
box-shadow: 0px 0px 0px 14px rgba(23, 101, 144, 0);
/*outline: 14px solid rgba(159, 204, 250, 0);
outline-offset: 0px;*/
}
100% {
border-color: rgb(122, 167, 221) !important;
border-radius: 1px;
box-shadow: 0px 0px 0px 3.5px rgba(23, 101, 144, 1);
/*outline: 4px solid rgba(159, 204, 250, 1);
outline-offset: -1px;*/
}
}
input[type='search'] {
text-indent: 0px;
text-align: center;
background-image: url('assets/search.svg');
background-size: auto 50%, 100% 100%;
background-position: 5px 6px, center;
background-repeat: no-repeat;
text-align: center;
text-indent: 18px;
height: 28px !important;
border-radius: 5px !important;
}
input[type='search']:focus {
text-align: left;
box-shadow: 0px 0px 0px 4.5px rgb(115, 166, 233);
border-bottom-color: #f00 !important;
border-radius: 4px !important;
}
input[type='search']::-webkit-input-placeholder {
/*text-align: center;*/
color: rgb(152, 152, 152);
font-weight: 400;
letter-spacing: 0.2px;
}
</style>

View File

@ -1,12 +1,12 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import Home from '../views/Home.vue'
import Chat from '../views/Chat/index.vue'
import Chat from '@/views/Chat'
import Blank from '@/views/Blank'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Home',
component: Home,
component: Blank,
},
{
path: '/chat/:id',

View File

@ -17,6 +17,9 @@ declare module '@/components/DownloadAttachment'
declare module '@/components/UploadButton'
declare module '@/components/ReactionMenu'
declare module '@/components/ReactionBubbles'
declare module '@/views/Home'
declare module '@/views/Chat'
declare module '@/views/Blank'
declare const __static: string
interface EmojiObject {

3
src/views/Blank.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

View File

@ -1,10 +1,10 @@
import { ipcRenderer, remote } from 'electron'
import emojiRegex from 'emoji-regex'
import { nextTick, onMounted, getCurrentInstance, reactive, ComponentInternalInstance, watch } from 'vue'
import { useRoute } from 'vue-router'
import { RouteLocationNormalizedLoaded, useRoute } from 'vue-router'
import { state as messagesState, fetchMessages } from './messages'
let route = useRoute()
let route: Nullable<RouteLocationNormalizedLoaded> = null
let currentInstance: Nullable<ComponentInternalInstance> = null
const state = reactive({
@ -49,7 +49,7 @@ const postLoad = (initial: boolean) => {
if (container.scrollHeight - (container.scrollTop + container.offsetHeight) <= 20) {
state.lastHeight = null
currentInstance?.emit('markAsRead', route.params.id)
currentInstance?.emit('markAsRead', route?.params.id)
} else state.lastHeight = container.scrollHeight - container.scrollTop
if (container.scrollTop == 0 && !messagesState.loading) {
@ -68,7 +68,7 @@ const postLoad = (initial: boolean) => {
return
}
currentInstance.emit('markAsRead', route.params.id)
currentInstance.emit('markAsRead', route?.params.id)
})
}
@ -105,52 +105,8 @@ const rightClickMessage = (args: object) => {
ipcRenderer.send('rightClickMessage', args)
}
const handleFiles = (files: FileList, text?: string, target?: HTMLElement) => {
if (text && target && text.length > 0) {
nextTick(() => {
let windowSelection = window.getSelection() as Selection
const newText =
target.innerHTML.slice(0, windowSelection.anchorOffset - text.length) + target.innerHTML.slice(windowSelection.anchorOffset)
target.innerHTML = newText
if (newText.length > 0) {
windowSelection = window.getSelection() as Selection
windowSelection.collapse(target.lastChild, newText.length)
}
})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uploadButtonRef = currentInstance?.refs.uploadButton as any
uploadButtonRef.$refs.fileInput.files = files
uploadButtonRef.filesChanged()
}
const hookPasteAndDrop = () => {
nextTick(() => {
const el = currentInstance?.refs.messageInput as HTMLElement
if (el) {
setTimeout(() => {
el.focus()
}, 10)
el.addEventListener('paste', e => {
if (e.clipboardData?.files && e.clipboardData.files.length > 0) {
const text = e.clipboardData.getData('Text')
const files = e.clipboardData.files
handleFiles(files, text, e.target as HTMLElement)
}
})
el.addEventListener('drop', e => {
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const files = e.dataTransfer.files
handleFiles(files)
}
})
}
})
}
const autoCompleteHooks = () => {
if (route.params.id == 'new') {
if (route?.params.id == 'new') {
nextTick(() => {
const input = document.getElementsByClassName('autocomplete-input')[0] as HTMLElement
if (input) {
@ -168,17 +124,14 @@ const autoCompleteInput = (input: { name: string; phone: string } | string) => {
if (inputObj && inputObj.name) {
messagesState.receiver = inputObj.phone
hookPasteAndDrop()
} else if (/^\+\d{11,16}/gi.test(input as string)) {
messagesState.receiver = input as string
hookPasteAndDrop()
} else if (/^([a-zA-Z0-9_.+-])+@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,6})+$/.test(input as string)) {
messagesState.receiver = input as string
hookPasteAndDrop()
}
}
export { postLoad, scrollToBottom, hookPasteAndDrop, state }
export { postLoad, scrollToBottom, autoCompleteHooks, autoCompleteInput, state }
export default () => {
route = useRoute()
@ -191,7 +144,7 @@ export default () => {
}
onMounted(init)
watch(() => route.params.id, init)
watch(() => route?.params.id, init)
return {
postLoad,

View File

@ -10,7 +10,12 @@
v-html="$filters.nativeEmoji(messages.state.messages[0].name)"
></span>
<span class="contact" v-else-if="$route.params.id == 'new'" style="overflow: visible;">
<autocomplete ref="autoComplete" :search="search" :get-result-value="getResultValue" @submit="onSubmit"></autocomplete>
<autocomplete
ref="autoComplete"
:search="input.search"
:get-result-value="input.getResultValue"
@submit="input.onSubmit"
></autocomplete>
</span>
</div>
<div class="closeBtn" @click="$router.push('/')">
@ -209,6 +214,9 @@
@blur="input.handleBlur"
@input="input.messageInputChanged"
@paste="input.messageInputPasted"
@drop="input.messageInputDropped"
@dragenter.prevent
@dragover.prevent
@keydown.enter.exact="input.enterKey"
@keydown.shift.enter="input.shiftEnterKey"
contenteditable
@ -233,6 +241,9 @@
@blur="input.handleBlur"
@input="input.messageInputChanged"
@paste="input.messageInputPasted"
@drop="input.messageInputDropped"
@dragenter.prevent
@dragover.prevent
@keydown.enter.exact="input.enterKey"
@keydown.shift.enter="input.shiftEnterKey"
contenteditable

View File

@ -1,15 +1,17 @@
import { ComponentInternalInstance, getCurrentInstance, nextTick, onMounted, reactive, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Router, useRoute, useRouter } from 'vue-router'
import emojiData from 'emoji-mart-vue-fast/data/all.json'
import { EmojiIndex } from 'emoji-mart-vue-fast/src/index'
import rangy from 'rangy'
import filters from '@/filters'
import 'rangy/lib/rangy-selectionsaverestore'
import { state as messagesState, sendSocket } from './messages'
import { autoCompleteHooks, autoCompleteInput } from './functions'
import axios from 'axios'
import { useStore } from 'vuex'
let currentInstance: Nullable<ComponentInternalInstance> = null
let router: Nullable<Router> = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const $rangy = rangy as any
@ -30,7 +32,28 @@ const state = reactive({
emojiIndex: new EmojiIndex(emojiData),
})
const handleFiles = (files: FileList, text?: string, target?: HTMLElement) => {
if (text && target && text.length > 0) {
nextTick(() => {
let windowSelection = window.getSelection() as Selection
const newText =
target.innerHTML.slice(0, windowSelection.anchorOffset - text.length) + target.innerHTML.slice(windowSelection.anchorOffset)
target.innerHTML = newText
if (newText.length > 0) {
windowSelection = window.getSelection() as Selection
windowSelection.collapse(target.lastChild, newText.length)
}
})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uploadButtonRef = currentInstance?.refs.uploadButton as any
uploadButtonRef.$refs.fileInput.files = files
uploadButtonRef.filesChanged()
}
export default () => {
router = useRouter()
const watchRoute = useRoute()
const store = useStore()
currentInstance = getCurrentInstance()
@ -186,11 +209,37 @@ export default () => {
const messageInputPasted = (e: ClipboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.clipboardData?.files && e.clipboardData.files.length > 0) {
let text = e.clipboardData?.getData('text/plain') as string
text = filters.escapeHTML(text)
const files = e.clipboardData.files
handleFiles(files, text, e.target as HTMLElement)
return
}
let paste = e.clipboardData?.getData('text/plain') as string
paste = filters.escapeHTML(paste)
document.execCommand('insertHTML', false, paste)
}
const messageInputDropped = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
console.log(e)
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
let text = e.dataTransfer?.getData('text/plain') as string
text = filters.escapeHTML(text)
const files = e.dataTransfer.files
handleFiles(files, text, e.target as HTMLElement)
}
let paste = e.dataTransfer?.getData('text/plain') as string
paste = filters.escapeHTML(paste)
document.execCommand('insertHTML', false, paste)
}
const messageInputChanged = (e: Event) => {
const target = e.target as HTMLElement
let content = filters.nativeEmoji(filters.colonEmoji(target.innerHTML))
@ -268,6 +317,39 @@ export default () => {
state.showEmojiMenu = false
}
const search = (input: string) => {
const url = `${store?.getters.httpURI}/search?text=${encodeURIComponent(input)}&auth=${encodeURIComponent(store?.state.password)}`
return new Promise(resolve => {
if (input.length < 2) {
return resolve([])
}
fetch(url)
.then(response => response.json())
.then(data => {
resolve(data.slice(0, 7))
})
})
}
const getResultValue = (result: { name: string; phone: string }) => {
return result.name + ` (${result.phone})`
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onSubmit = (result: any) => {
if (result && result.personId) {
router?.push('/message/' + result.personId)
} else if (result) {
autoCompleteInput(result)
} else {
const input = currentInstance?.refs.autoComplete as { value: string }
if (input) {
autoCompleteInput(input.value)
}
}
nextTick(autoCompleteHooks)
}
onMounted(init)
watch(() => watchRoute.params.id, init)
@ -277,11 +359,15 @@ export default () => {
previewFiles,
removeAttachment,
messageInputPasted,
messageInputDropped,
messageInputChanged,
handleBlur,
insertEmoji,
toggleEmojiMenu,
hideEmojiMenu,
search,
getResultValue,
onSubmit,
state,
}
}

View File

@ -1,6 +1,6 @@
import { reactive, computed, ComputedRef, watch, ComponentInternalInstance, getCurrentInstance, nextTick } from 'vue'
import main from '@/main'
import { postLoad, scrollToBottom, hookPasteAndDrop, state as functionsState } from './functions'
import { postLoad, scrollToBottom, state as functionsState } from './functions'
import { parseBuffer } from 'bplist-parser'
import moment from 'moment'
@ -143,13 +143,10 @@ const fetchMessagesHandler = (response: any) => {
if (state.offset == 0) {
state.messages = data
postLoad(true)
// this.hookPasteAndDrop()
} else {
state.messages.push(...data)
postLoad(false)
}
// state.loading = false
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -293,13 +290,29 @@ const onSocketConnected = () => {
fetchMessages()
}
const hookSocketEvents = () => {
const socket: Nullable<WebSocket> = main ? main.config.globalProperties.$socket : null
if (socket) {
socket.removeEventListener('message', onSocketMessage)
socket.removeEventListener('close', onSocketDisconnect)
socket.removeEventListener('error', onSocketDisconnect)
socket.removeEventListener('open', onSocketConnected)
socket.addEventListener('message', onSocketMessage)
socket.addEventListener('close', onSocketDisconnect)
socket.addEventListener('error', onSocketDisconnect)
socket.addEventListener('open', onSocketConnected)
} else {
setTimeout(hookSocketEvents, 1000)
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fetchMessages = () => {
const socket: Nullable<WebSocket> = main ? main.config.globalProperties.$socket : null
if (route && state.offset == 0 && store && store.state.messagesCache[route.params.id as string]) {
state.messages = store.state.messagesCache[route.params.id as string].map((o: object) => ({ ...o }))
postLoad(true)
hookPasteAndDrop()
hookSocketEvents()
return
}
@ -316,14 +329,7 @@ const fetchMessages = () => {
},
})
socket.removeEventListener('message', onSocketMessage)
socket.removeEventListener('close', onSocketDisconnect)
socket.removeEventListener('error', onSocketDisconnect)
socket.removeEventListener('open', onSocketConnected)
socket.addEventListener('message', onSocketMessage)
socket.addEventListener('close', onSocketDisconnect)
socket.addEventListener('error', onSocketDisconnect)
socket.addEventListener('open', onSocketConnected)
hookSocketEvents()
} else {
setTimeout(fetchMessages, 1000)
}

View File

@ -1,9 +0,0 @@
<template>
<div class="home"></div>
</template>
<script>
export default {
name: 'Home',
}
</script>

449
src/views/Home/chats.ts Normal file
View File

@ -0,0 +1,449 @@
import { reactive, computed, ComputedRef, onMounted, getCurrentInstance, ComponentInternalInstance } from 'vue'
import main from '@/main'
import { RouteLocationNormalizedLoaded, Router, useRoute, useRouter } from 'vue-router'
import { Store, useStore } from 'vuex'
import { remote } from 'electron'
import { state as windowState } from './window'
import { state as notificationsState, sendNotifierNotification } from './notifications'
interface ChatObject {
showNum: boolean
address: string
text: string
author: string
date: number
personId: string
read: boolean
}
let route: Nullable<RouteLocationNormalizedLoaded> = null
let router: Nullable<Router> = null
let currentInstance: Nullable<ComponentInternalInstance> = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let store: Nullable<Store<any>> = null
const state = reactive({
chats: [] as ChatObject[],
limit: 50,
offset: 0,
loading: false,
search: '',
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const filteredChats: ComputedRef<any> = computed(() => {
let chats = state.chats.slice().sort((a, b) => (b.date - a.date > 0 ? 1 : -1))
if (chats.length > 0) {
chats = chats.reduce((r: ChatObject[], chat) => {
chat.showNum = false
const duplicateIndex = chats.findIndex(obj => obj.author == chat.author && obj.address != chat.address)
if (duplicateIndex > -1 && !chat.address.startsWith('chat') && (chat.address.startsWith('+') || chat.address.includes('@'))) {
chat.showNum = true
}
r.push(chat)
return r
}, [])
}
return chats.filter(chat => {
return chat.author.toLowerCase().includes(state.search.toLowerCase()) || chat.text.toLowerCase().includes(state.search.toLowerCase())
})
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendSocket = (obj: { action: string; data: object; password?: string }) => {
const socket: Nullable<WebSocket> = main ? main.config.globalProperties.$socket : null
if (!socket || !store) return
obj.password = store.state.password
socket.send(JSON.stringify(obj))
}
const markAsRead = (val: string) => {
const chatIndex = state.chats.findIndex(obj => obj.personId == val)
if (chatIndex > -1) {
const chat = state.chats[chatIndex]
if (!chat.read) {
if (document.hasFocus()) {
chat.read = true
sendSocket({
action: 'markAsRead',
data: { chatId: route?.params.id },
})
} else {
const onFocusHandler = () => {
if (!chat.read && route?.path == '/chat/' + val) {
chat.read = true
sendSocket({
action: 'markAsRead',
data: { chatId: route.params.id },
})
}
window.removeEventListener('focus', onFocusHandler)
}
window.addEventListener('focus', onFocusHandler)
}
}
}
}
const cacheMessages = () => {
if (!store?.state.cacheMessages) return
const socket: Nullable<WebSocket> = main ? main.config.globalProperties.$socket : null
if (socket?.readyState == 1) {
for (let i = 0; i < state.chats.length; i++) {
const chat = state.chats[i]
sendSocket({
action: 'fetchMessages',
data: {
id: chat.personId,
offset: `0`,
limit: `25`,
},
})
}
} else {
setTimeout(cacheMessages, 1000)
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fetchChatsHandler = (response: any) => {
const data = response.data
if (state.offset == 0) state.chats = data
else state.chats.push(...data)
state.offset += state.limit
state.loading = false
cacheMessages()
windowState.status = 2
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fetchMessagesHandler = (response: any) => {
const data = response.data
if (data && data[0] && !store?.state.messagesCache[data[0].personId]) {
store?.commit('addMessages', { id: data[0].personId, data: data })
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newMessageHandler = (response: any) => {
const data = response.data
const chatData = data.chat[0]
if (chatData && chatData.personId) {
const chatIndex = state.chats.findIndex(obj => obj.personId == chatData.personId)
if (chatIndex > -1) state.chats.splice(chatIndex, 1)
state.chats.unshift(chatData)
}
const messageData = data.message[0]
if (messageData) {
store?.commit('setTyping', {
chatId: messageData.personId,
isTyping: false,
})
if (store?.state.messagesCache[messageData.personId]) {
const oldMsgIndex = store?.state.messagesCache[messageData.personId].findIndex(
(obj: { guid: string }) => obj.guid == messageData.guid
)
if (oldMsgIndex != -1 && store) {
store.state.messagesCache[messageData.personId][oldMsgIndex] = messageData
return
}
store?.state.messagesCache[messageData.personId].unshift(messageData)
}
if (messageData.sender != 1 && remote.Notification.isSupported()) {
if (store?.state.mutedChats.includes(messageData.personId)) return
// if (this.lastNotificationGUID == messageData.guid) return
if (route?.params.id == messageData.personId && document.hasFocus()) return
let body = messageData.text.replace(/\u{fffc}/gu, '')
if (messageData.group && messageData.group.startsWith('chat')) {
body = `${messageData.author}: ${body}`
}
// this.lastNotificationGUID = messageData.guid
const notificationOptions = {
appID: 'com.sgtaziz.WebMessage',
title: messageData.name as string,
message: (body == '' ? 'Attachment' : body) as string,
sound: store?.state.systemSound as boolean,
reply: true,
icon: null as Nullable<string>,
}
if (document.hasFocus()) return
sendNotifierNotification(notificationOptions, messageData, chatData)
} else if (messageData.sender != 1) {
console.log('Notifications are not supported on this system.')
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newReactionHandler = (response: any) => {
const data = response.data
const reactions = data.reactions
if (reactions && reactions.length > 0 && store?.state.messagesCache[reactions[0].personId]) {
const msgIndex = store?.state.messagesCache[reactions[0].personId].findIndex(
(obj: { guid: string }) => obj.guid == reactions[0].forGUID
)
if (msgIndex > -1 && store) {
store.state.messagesCache[reactions[0].personId][msgIndex]['reactions'] = reactions
}
}
const chatData = data.chat[0]
if (chatData && chatData.personId) {
const chatIndex = state.chats.findIndex(obj => obj.personId == chatData.personId)
if (chatIndex > -1) state.chats.splice(chatIndex, 1)
state.chats.unshift(chatData)
}
if (reactions && reactions.length > 0 && reactions[0].sender != 1 && remote.Notification.isSupported()) {
const reaction = reactions[0]
if (store?.state.mutedChats.includes(reaction.personId)) return
// if (this.lastNotificationGUID == reaction.guid) return
if (route?.params.id == reaction.personId && document.hasFocus()) return
// this.lastNotificationGUID = reaction.guid
const notificationOptions = {
appID: 'com.sgtaziz.WebMessage',
title: chatData.author as string,
message: reaction.text.replace(/\u{fffc}/gu, '') as string,
sound: store?.state.systemSound as boolean,
}
if (document.hasFocus()) return
sendNotifierNotification(notificationOptions, reaction, chatData)
} else if (reactions && reactions.length > 0 && reactions[0].sender != 1) {
console.log('Notifications are not supported on this system.')
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const setAsReadHandler = (response: any) => {
const data = response.data
if (store?.state.messagesCache[data.chatId]) {
const messageIndex = store.state.messagesCache[data.chatId].findIndex((obj: { guid: string }) => obj.guid == data.guid)
if (messageIndex > -1) {
store.state.messagesCache[data.chatId][messageIndex]['dateRead'] = data.read
store.state.messagesCache[data.chatId][messageIndex]['dateDelivered'] = data.delivered
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const setTypingIndicatorHandler = (response: any) => {
const data = response.data
if (data && data.chat_id) {
const chatId = data.chat_id
const typing = data.typing == true
if (chatId) store?.commit('setTyping', { chatId: chatId, isTyping: typing })
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const removeChatHandler = (response: any) => {
const data = response.data
if (data.chatId) {
const chatId = data.chatId
const chatIndex = state.chats.findIndex(obj => obj.address == chatId)
if (chatIndex > -1) {
const chat = state.chats[chatIndex]
if (route?.path == '/chat/' + chat.personId) {
router?.push('/')
}
if (store) store.state.messagesCache[chat.personId] = null
state.chats.splice(chatIndex, 1)
}
}
}
const onSocketMessage = (event: MessageEvent) => {
event.data.text().then((responseText: string) => {
const response = JSON.parse(responseText)
if (store && route && response) {
switch (response.action) {
case 'fetchChats':
fetchChatsHandler(response)
break
case 'fetchMessages':
fetchMessagesHandler(response)
break
case 'newMessage':
newMessageHandler(response)
break
case 'newReaction':
newReactionHandler(response)
break
case 'setAsRead':
setAsReadHandler(response)
break
case 'setTypingIndicator':
setTypingIndicatorHandler(response)
break
case 'removeChat':
removeChatHandler(response)
break
default:
return
}
}
})
}
const onSocketDisconnect = (e: Event | CloseEvent) => {
const socket: Nullable<WebSocket> = main ? main.config.globalProperties.$socket : null
state.loading = false
if (socket?.readyState == 1) return
windowState.status = e instanceof CloseEvent ? 0 : 1
state.offset = 0
store?.commit('resetMessages')
router?.push('/').catch(() => {})
state.chats = []
}
const onSocketConnected = () => {
state.chats = []
state.offset = 0
windowState.status = 1
state.loading = false
// eslint-disable-next-line @typescript-eslint/no-use-before-define
fetchChats()
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fetchChats = () => {
const socket: Nullable<WebSocket> = main ? main.config.globalProperties.$socket : null
if (socket && socket.readyState == 1) {
if (state.loading || !route) return
state.loading = true
sendSocket({
action: 'fetchChats',
data: {
offset: `${state.offset}`,
limit: `${state.limit}`,
},
})
} else {
setTimeout(fetchChats, 1000)
}
}
const deleteChat = (chat: ChatObject) => {
const chatIndex = state.chats.findIndex(obj => obj.personId == chat.personId)
if (chatIndex > -1) {
state.chats.splice(chatIndex, 1)
}
if (route?.path == '/chat/' + chat.personId) {
router?.push('/')
}
if (store) store.state.messagesCache[chat.personId] = null
}
const composeMessage = () => {
if (windowState.status == 2) router?.push('/chat/new')
}
const connectWS = () => {
const connect: Nullable<CallableFunction> = main ? main.config.globalProperties.$connect : null
const disconnect: Nullable<CallableFunction> = main ? main.config.globalProperties.$disconnect : null
if (disconnect) disconnect()
state.offset = 0
state.loading = false
windowState.status = 0
state.chats = []
store?.commit('resetMessages')
router?.push('/').catch(() => {})
notificationsState.notifSound = new Audio(store?.state.notifSound)
const baseURI = store?.getters.baseURI
if (connect) {
connect(baseURI, {
format: 'json',
reconnection: true,
})
const socket: Nullable<WebSocket> = main ? main.config.globalProperties.$socket : null
socket?.removeEventListener('message', onSocketMessage)
socket?.removeEventListener('close', onSocketDisconnect)
socket?.removeEventListener('error', onSocketDisconnect)
socket?.removeEventListener('open', onSocketConnected)
socket?.addEventListener('message', onSocketMessage)
socket?.addEventListener('close', onSocketDisconnect)
socket?.addEventListener('error', onSocketDisconnect)
socket?.addEventListener('open', onSocketConnected)
} else {
setTimeout(connectWS, 1)
}
}
export { state }
export default () => {
store = useStore()
route = useRoute()
router = useRouter()
currentInstance = getCurrentInstance()
const init = () => {
state.chats = []
state.limit = 50
state.offset = 0
state.loading = false
state.search = ''
connectWS()
}
init()
const mounted = () => {
const container = currentInstance?.refs.chats as Nullable<HTMLElement>
if (container) {
container.addEventListener('scroll', () => {
if (container.scrollTop + container.offsetHeight == container.scrollHeight && !state.loading) {
fetchChats()
}
})
} else {
setTimeout(mounted, 1)
}
}
onMounted(mounted)
return {
state,
filteredChats,
fetchChats,
deleteChat,
composeMessage,
markAsRead,
}
}

473
src/views/Home/index.vue Normal file
View File

@ -0,0 +1,473 @@
<template>
<!-- <vue-confirm-dialog class="confirmDialog"></vue-confirm-dialog> -->
<settings ref="settingsModal" @saved="chats.connectWS"></settings>
<div id="nav" :class="{ notrans: !$store.state.acceleration }">
<div class="titlebar">
<div class="buttons" v-if="$store.state.macstyle || window.process.platform === 'darwin'">
<div class="close" @click="window.closeWindow">
<span class="closebutton"><span>x</span></span>
</div>
<div class="minimize" @click="window.minimizeWindow">
<span class="minimizebutton"><span>&ndash;</span></span>
</div>
<div class="zoom" @click="window.maximizeWindow">
<span class="zoombutton"><span>+</span></span>
</div>
</div>
<div class="statusIndicator" style="margin-top: 1px;" v-tooltip:bottom.tooltip="window.statusText.value">
<feather type="circle" stroke="rgba(25,25,25,0.5)" :fill="window.statusColor.value" size="10"></feather>
</div>
<div class="menuBtn">
<feather type="settings" stroke="rgba(152,152,152,0.5)" size="20" @click="$refs.settingsModal.openModal()"></feather>
</div>
<div class="menuBtn">
<feather type="refresh-cw" stroke="rgba(152,152,152,0.5)" size="19" @click="chats.connectWS"></feather>
</div>
<div class="menuBtn">
<feather type="edit" stroke="rgba(36,132,255,0.65)" size="20" @click="chats.composeMessage"></feather>
</div>
<div class="menuBtn" v-if="window.updateAvailable">
<feather type="download" stroke="rgba(152,255,152,0.65)" size="20" @click="window.restart"></feather>
</div>
</div>
<div class="searchContainer">
<input type="search" placeholder="Search" class="textinput" v-model="chats.search" />
</div>
<div class="chats scrollable" ref="chatsContainer">
<chat
v-for="chat in chats.filteredChats.value"
:key="chat.id"
:chatid="chat.personId"
:author="chat.author"
:text="chat.text"
:date="chat.date"
:read="chat.read"
:docid="chat.docid"
:showNum="chat.showNum"
:isGroup="chat.personId.startsWith('chat') && !chat.personId.includes('@') && chat.personId.length >= 20"
@deleted="deleteChat(chat)"
/>
</div>
</div>
<div id="content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" @markAsRead="chats.markAsRead" />
</transition>
</router-view>
</div>
</template>
<script>
import Chat from '@/components/Chat.vue'
import Settings from '@/components/Settings.vue'
import Tooltip from '@/components/Tooltip.vue'
import chatsComp from './chats'
import windowComp from './window'
import notificationsComp from './notifications'
export default {
name: 'App',
components: {
Chat,
Settings,
Tooltip,
},
setup() {
const chats = chatsComp()
const window = windowComp()
const notifications = notificationsComp()
return { chats, window, notifications }
},
}
</script>
<style lang="scss">
.text-emoji {
width: 16px;
height: 16px;
margin-top: -2px;
margin-bottom: -3px;
// margin-right: 1px;
}
.confirmDialog {
.vc-container {
background-color: rgba(45, 45, 45, 0.9);
border: 1px solid rgba(25, 25, 25, 0.9);
.vc-text {
color: white;
font-weight: 300;
font-size: 14px;
}
.vc-title {
color: white;
font-weight: 500;
}
}
.vc-btn {
background-color: rgba(45, 45, 45, 0.9);
border-color: rgba(25, 25, 25, 0.9) !important;
color: rgba(255, 0, 0, 0.9);
&:hover {
background-color: rgba(45, 45, 45, 0.9);
filter: brightness(90%);
}
&.left {
color: #4083ff;
}
}
}
.vue-popover {
background: rgba(25, 25, 25, 0.8) !important;
font-size: 14px;
&::before {
border-bottom-color: rgba(25, 25, 25, 0.8) !important;
}
}
.emoji {
position: relative;
top: 0.15em;
width: 13px;
}
.searchContainer {
padding-right: 6px;
}
.menuBtn {
-webkit-app-region: no-drag;
width: auto;
float: right;
margin-right: 10px;
.feather {
cursor: pointer;
&:hover {
filter: brightness(80%);
}
}
}
.statusIndicator {
width: auto;
float: right;
margin-right: 10px;
-webkit-app-region: no-drag;
}
.textinput {
background-color: rgba(200, 200, 200, 0.1);
width: calc(100% - 4px);
margin: 0 !important;
margin-left: -2px !important;
border: 0px none;
border-color: rgba(87, 87, 87, 0.7) !important;
color: #ebecec !important;
text-align: left !important;
}
.chats {
margin-top: 12px;
max-height: calc(100% - 73px);
margin-right: 1px;
overflow-y: auto;
overflow-x: hidden;
.simplebar-scrollbar:before {
background: #575757;
}
}
.scrollable {
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: #424241;
border-radius: 16px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
@import url('https://fonts.googleapis.com/css?family=Roboto:light,regular,medium,thin,italic,mediumitalic,bold');
html {
height: 100%;
max-height: 100%;
width: 100%;
background-color: rgba(29, 29, 29, 0);
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, Avenir, Helvetica, Arial, sans-serif;
font-weight: 300;
}
body {
margin: 0;
height: calc(100% - 2px);
max-height: 100%;
width: calc(100% - 2px);
background-color: rgba(29, 29, 29, 0);
overflow: hidden;
border: 1px solid rgb(0, 0, 0);
border-radius: 10px;
}
#app {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, Avenir, Helvetica, Arial, sans-serif;
font-weight: 300;
text-align: center;
color: #ebecec;
position: absolute;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
background-color: rgba(29, 29, 29, 0);
border: 1px solid #4a4a4a;
border-radius: 10px;
&.maximized {
border-radius: 0;
#nav {
border-radius: 0;
}
#content {
border-radius: 0;
}
}
&.nostyle {
background-color: rgba(40, 40, 40, 1);
border: none;
border-radius: 0;
height: 100%;
width: 100%;
#nav {
border-radius: 0 !important;
margin-left: -1px;
margin-top: -1px;
width: 312px;
}
#content {
bottom: 0;
right: 0;
top: 0;
border-radius: 0;
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease, backdrop-filter 0.2s ease;
}
.fade-enter-from,
.fade-leave-active {
opacity: 0;
}
#nav {
background-color: rgba(40, 40, 40, 0.93);
width: 311px;
padding: 9px;
padding-right: 0;
float: left;
position: absolute;
top: 0;
left: 0;
bottom: 0;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
&.notrans {
background-color: rgba(40, 40, 40, 1);
}
a {
font-weight: bold;
color: #2c3e50;
&.router-link-exact-active {
color: #42b983;
}
}
input:not([type='range']):not([type='color']):not(.message-input) {
height: auto;
height: inherit;
font-size: 13px;
height: 6px;
padding: 10px 6px 10px 6px;
outline: none;
border: 1px solid rgb(213, 213, 213);
margin: 5px;
cursor: text;
-webkit-app-region: no-drag;
}
input:not([type='range']):not([type='color']):not(.message-input):focus {
border-radius: 1px;
box-shadow: 0px 0px 0px 3.5px rgba(23, 101, 144, 1);
animation: showFocus 0.3s;
border-color: rgb(122, 167, 221) !important;
}
}
.titlebar {
-webkit-app-region: drag;
width: calc(100% - 10px);
height: 30px;
padding: 5px;
padding-top: 7px;
}
.buttons {
-webkit-app-region: no-drag;
padding-left: 5px;
padding-top: 3px;
padding-right: 20px;
float: left;
line-height: 0px;
div:hover {
filter: brightness(75%);
}
}
.close {
background: #ff5c5c;
font-size: 9pt;
line-height: 12px;
width: 12px;
height: 12px;
border: 0px solid #e33e41;
border-radius: 50%;
display: inline-block;
}
.close:active {
background: #c14645;
}
.closebutton {
color: #820005;
visibility: hidden;
cursor: default;
}
.minimize {
background: #ffbd4c;
font-size: 9pt;
line-height: 12px;
margin-left: 8px;
width: 12px;
height: 12px;
border: 0px solid #e09e3e;
border-radius: 50%;
display: inline-block;
}
.minimize:active {
background: #c08e38;
}
.minimizebutton {
color: #9a5518;
visibility: hidden;
cursor: default;
}
.zoom {
background: #00ca56;
font-size: 9pt;
line-height: 12px;
margin-left: 8px;
width: 12px;
height: 12px;
border: 0px solid #14ae46;
border-radius: 50%;
display: inline-block;
}
.zoom:active {
background: #029740;
}
.zoombutton {
color: #006519;
visibility: hidden;
cursor: default;
}
#content {
float: left;
background-color: rgba(29, 29, 29, 1);
position: fixed;
top: 1px;
left: 321px;
right: 1px;
bottom: 1px;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
border-left: 1px solid #000;
overflow: hidden;
}
@keyframes showFocus {
0% {
border-color: #dbdbdb;
border-radius: -1px;
box-shadow: 0px 0px 0px 14px rgba(23, 101, 144, 0);
/*outline: 14px solid rgba(159, 204, 250, 0);
outline-offset: 0px;*/
}
100% {
border-color: rgb(122, 167, 221) !important;
border-radius: 1px;
box-shadow: 0px 0px 0px 3.5px rgba(23, 101, 144, 1);
/*outline: 4px solid rgba(159, 204, 250, 1);
outline-offset: -1px;*/
}
}
input[type='search'] {
text-indent: 0px;
text-align: center;
background-image: url('../../assets/search.svg');
background-size: auto 50%, 100% 100%;
background-position: 5px 6px, center;
background-repeat: no-repeat;
text-align: center;
text-indent: 18px;
height: 28px !important;
border-radius: 5px !important;
}
input[type='search']:focus {
text-align: left;
box-shadow: 0px 0px 0px 4.5px rgb(115, 166, 233);
border-bottom-color: #f00 !important;
border-radius: 4px !important;
}
input[type='search']::-webkit-input-placeholder {
/*text-align: center;*/
color: rgb(152, 152, 152);
font-weight: 400;
letter-spacing: 0.2px;
}
</style>

View File

@ -0,0 +1,89 @@
import { ipcRenderer } from 'electron'
import { reactive } from 'vue'
import { Router, useRouter } from 'vue-router'
import { Store, useStore } from 'vuex'
interface NotificationOptions {
appID: string
title: string
message: string
sound: boolean
reply?: boolean
icon?: Nullable<string>
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let store: Nullable<Store<any>> = null
let router: Nullable<Router> = null
const state = reactive({
notifSound: null as Nullable<HTMLAudioElement>,
})
const sendNotifierNotification = (
options: NotificationOptions,
messageData: { authorDocid: string; personId: string },
chatData: { id: string }
) => {
const path = require('path')
const fs = require('fs')
const https = require('https')
const tempDir = path.join(__dirname, '..', 'temp')
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir)
}
const download = require('image-downloader')
const fileDir = path.join(tempDir, `avatar_${messageData.authorDocid}.jpg`)
const dlOptions = {
url: `${store?.getters.httpURI}/contactimg?docid=${messageData.authorDocid}&auth=${encodeURIComponent(store?.state.password)}`,
dest: fileDir,
agent: new https.Agent({ rejectUnauthorized: false }),
}
download
.image(dlOptions)
.then(() => {
options.icon = fileDir
if (fs.statSync(fileDir).size == 0) throw Error('Empty image')
})
.catch(() => {
options.icon = __static + '/icon.png'
})
.finally(() => {
if (!store?.state.systemSound) state.notifSound?.play()
const NotificationCenter = require('node-notifier').NotificationCenter
let Notifier = null
if (process.platform === 'darwin') {
Notifier = new NotificationCenter({
withFallback: true,
customPath: path.join(
__dirname,
'../terminal-notifier/vendor/mac.noindex/terminal-notifier.app/Contents/MacOS/terminal-notifier'
),
})
} else {
Notifier = require('node-notifier')
}
Notifier.notify(options, (err: string, action: string) => {
if (err) return
if ((action == 'activate' || action == undefined) && chatData && chatData.id) {
ipcRenderer.send('show_win')
router?.push('/chat/' + messageData.personId)
}
})
})
}
export { state, sendNotifierNotification }
export default () => {
store = useStore()
router = useRouter()
return { state }
}

165
src/views/Home/window.ts Normal file
View File

@ -0,0 +1,165 @@
import { reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { Router, useRouter } from 'vue-router'
import { Store, useStore } from 'vuex'
import { BrowserWindow, ipcRenderer, remote } from 'electron'
import { state as chatsState } from './chats'
let router: Nullable<Router> = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let store: Nullable<Store<any>> = null
const state = reactive({
updateAvailable: false,
maximized: false,
maximizing: false,
win: null as Nullable<BrowserWindow>,
status: 0,
})
const statusColor = computed(() => {
if (state.status == 0) {
return 'rgba(255,0,0,0.8)'
} else if (state.status == 1) {
return 'rgba(255,100,0,0.8)'
} else if (state.status == 2) {
return 'rgba(0,255,0,0.5)'
}
return ''
})
const statusText = computed(() => {
if (state.status == 0) {
return 'Device not found'
} else if (state.status == 1) {
return 'Device found. Retrieving data...'
} else if (state.status == 2) {
return 'Device connected'
}
return ''
})
const closeWindow = () => {
state.win?.close()
}
const minimizeWindow = () => {
state.win?.minimize()
}
const maximizeWindow = () => {
state.maximizing = true
if (state.maximized) {
state.win?.restore()
state.win?.setSize(700, 600)
state.win?.center()
if (process.platform !== 'darwin') document.body.style.borderRadius = ''
} else {
state.win?.maximize()
if (process.platform !== 'darwin') document.body.style.borderRadius = '0'
}
state.maximized = !state.maximized
setTimeout(() => {
state.maximizing = false
}, 50)
}
const onMove = (e: Electron.Event) => {
e.preventDefault()
if (state.maximizing) return
if (state.maximized) {
state.win?.restore()
if (process.platform !== 'darwin') document.body.style.borderRadius = ''
state.maximized = false
}
}
const restart = () => {
ipcRenderer.send('restart_app')
}
export { state }
export default () => {
store = useStore()
router = useRouter()
onBeforeUnmount(() => {
$(window).off('resize')
state.win?.removeListener('move', onMove)
ipcRenderer.removeAllListeners('win_id')
})
const process = window.process
onMounted(() => {
$(document).mousedown(event => {
if (event.which == 3) {
//this is a right click, so electron-context-menu will be appearing momentarily...
ipcRenderer.send('rightClickMessage', null)
}
})
ipcRenderer.send('loaded')
ipcRenderer.on('update_available', () => {
ipcRenderer.removeAllListeners('update_available')
})
ipcRenderer.on('update_downloaded', () => {
ipcRenderer.removeAllListeners('update_downloaded')
state.updateAvailable = true
})
ipcRenderer.on('navigateTo', (sender, id) => {
if (isNaN(id)) {
router?.push('/chat/new').catch(() => {})
} else {
const arrayId = parseInt(id) - 1
if (chatsState.chats[arrayId]) {
router?.push('/chat/' + chatsState.chats[arrayId].personId).catch(() => {})
}
}
})
ipcRenderer.on('win_id', (e, id) => {
state.win = remote.BrowserWindow.fromId(id)
window.addEventListener('resize', () => {
if (state.maximizing) return
if (state.maximized) {
state.win?.restore()
if (process.platform !== 'darwin') document.body.style.borderRadius = ''
state.maximized = false
}
})
state.win?.on('move', onMove)
if (!(store?.state.macstyle || process.platform === 'darwin')) {
document.body.style.border = 'none'
document.body.style.borderRadius = '0'
}
if (!store?.state.acceleration) {
document.documentElement.style.backgroundColor = 'black'
}
})
})
return {
state,
statusColor,
statusText,
process,
closeWindow,
minimizeWindow,
maximizeWindow,
restart,
}
}