Initial rewrite (Vue3, Electron 11, TypeScript)

This commit is contained in:
Aziz Hasanain 2021-03-20 21:17:37 +03:00
parent a1c7187c1d
commit 58fa723720
47 changed files with 8540 additions and 6617 deletions

36
.eslintrc.js Normal file
View File

@ -0,0 +1,36 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/typescript/recommended',
'@vue/prettier',
'@vue/prettier/@typescript-eslint',
],
parserOptions: {
ecmaVersion: 2020,
},
globals: {
__static: true,
$: true,
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'@typescript-eslint/no-empty-function': ['off'],
'@typescript-eslint/no-var-requires': ['off'],
'prettier/prettier': [
'warn',
{
printWidth: 140,
singleQuote: true,
semi: false,
trailingComma: 'es5',
tabWidth: 2,
},
],
},
}

View File

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

View File

@ -8,6 +8,7 @@
"serve": "vue-cli-service electron:serve",
"build": "vue-cli-service electron:build",
"publish": "yarn build -mwl -p always",
"lint": "vue-cli-service lint",
"generate-icons": "electron-icon-builder --input=./public/icon.png --output=build --flatten",
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps"
@ -20,53 +21,67 @@
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/vue-fontawesome": "^2.0.2",
"@fortawesome/vue-fontawesome": "^3.0.0-3",
"@techassi/vue-lazy-image": "^1.0.0",
"@trevoreyre/autocomplete-vue": "^2.2.0",
"auto-launch": "^5.0.5",
"axios": "^0.21.1",
"bplist-parser": "^0.2.0",
"bplist-parser": "^0.3.0",
"core-js": "^3.6.5",
"electron-context-menu": "^2.3.1",
"electron-context-menu": "^2.5.0",
"electron-localshortcut": "^3.2.1",
"electron-store": "^6.0.1",
"electron-updater": "^4.3.5",
"emoji-mart-vue-fast": "^8.0.3",
"electron-store": "^7.0.2",
"electron-updater": "^4.3.8",
"emoji-datasource": "^6.0.1",
"emoji-mart-vue-fast": "https://github.com/sgtaziz/emoji-mart-vue",
"emoji-regex": "^9.2.2",
"feather-icons": "^4.28.0",
"image-downloader": "^4.0.2",
"imagesloaded": "^4.1.4",
"jquery": "^3.5.1",
"jquery": "^3.6.0",
"moment": "^2.29.1",
"node-notifier": "^9.0.1",
"rangy": "^1.3.0",
"simplebar": "^5.3.0",
"simplebar-vue": "^1.6.0",
"usbmux": "^0.1.0",
"v-lazy-image": "^1.4.0",
"vue": "^2.6.11",
"vue": "^3.0.0",
"vue-avatar": "^2.3.3",
"vue-class-component": "^8.0.0-0",
"vue-confirm-dialog": "^1.0.2",
"vue-feather": "^1.1.1",
"vue-feather": "^2.0.0-beta",
"vue-js-popover": "^1.2.1",
"vue-linkify": "^1.0.1",
"vue-long-click": "^0.0.4",
"vue-native-websocket": "^2.0.14",
"vue-router": "^3.2.0",
"vuex": "^3.4.0"
"vue-native-websocket-vue3": "^3.1.0",
"vue-router": "^4.0.0-0",
"vuex": "^4.0.0-0"
},
"devDependencies": {
"@types/auto-launch": "^5.0.1",
"@types/electron-devtools-installer": "^2.2.0",
"@types/electron-localshortcut": "^3.1.0",
"@types/jquery": "^3.5.5",
"@types/rangy": "^0.0.33",
"@typescript-eslint/eslint-plugin": "^2.33.0",
"@typescript-eslint/parser": "^2.33.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"electron": "^9.4.0",
"electron-icon-builder": "^2.0.1",
"electron-log": "^4.3.1",
"@vue/compiler-sfc": "^3.0.0",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/eslint-config-typescript": "^5.0.2",
"electron": "^11.0.0",
"electron-devtools-installer": "^3.1.0",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-vue": "^7.0.0-0",
"node-sass": "^4.12.0",
"prettier": "^1.19.1",
"sass-loader": "^8.0.2",
"trash-cli": "^4.0.0",
"vue-cli-plugin-electron-builder": "~2.0.0-rc.5",
"vue-devtools": "^5.1.4",
"vue-template-compiler": "^2.6.11"
"typescript": "~3.9.3",
"vue-cli-plugin-electron-builder": "~2.0.0-rc.6"
}
}

View File

@ -1,79 +1,78 @@
<template>
<div id="app" :class="{ nostyle: !($store.state.macstyle || process.platform === 'darwin'), maximized: (maximized || !$store.state.acceleration) && process.platform !== 'darwin' }">
<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>
<!-- <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="statusIndicator" style="margin-top: 1px;">
<feather type="circle" stroke="rgba(25,25,25,0.5)" :fill="statusColor" size="10" v-popover:status.bottom></feather>
<div class="minimize" @click="minimizeWindow">
<span class="minimizebutton"><span>&ndash;</span></span>
</div>
<popover name="status" event="hover" transition="fade">
{{ statusText }}
</popover>
<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 class="zoom" @click="maximizeWindow">
<span class="zoombutton"><span>+</span></span>
</div>
</div>
<div class="searchContainer">
<input type="search" placeholder="Search" class="textinput" v-model="search" />
<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>
<simplebar class="chats" ref="chats" data-simplebar-auto-hide="false">
<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)">
</chat>
</simplebar>
</div>
<div id="content">
<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">
<router-view @markAsRead="markAsRead"></router-view>
<component :is="Component" @markAsRead="markAsRead" />
</transition>
</div>
</router-view>
</div>
</template>
<script>
import Chat from './components/Chat.vue'
import Settings from "./components/Settings.vue"
import simplebar from 'simplebar-vue'
import 'simplebar/dist/simplebar.css'
import axios from 'axios'
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'
export default {
name: 'App',
components: {
Chat,
simplebar,
Settings
Settings,
Tooltip,
},
data: function () {
data: () => {
return {
chats: [],
limit: 50,
@ -87,18 +86,19 @@ export default {
maximizing: false,
win: null,
status: 0, // 0 for disconnected, 1 for connecting, 2 for connected,
lastNotificationGUID: ''
lastNotificationGUID: '',
}
},
mixins: [socketMixin],
computed: {
filteredChats() {
let chats = this.chats
let chats = this.chats.slice()
if (chats.length > 0) {
chats = chats.reduce((r, chat) => {
chat.showNum = false
let duplicateIndex = this.chats.findIndex(obj => obj.author == chat.author && obj.address != chat.address)
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
}
@ -108,66 +108,80 @@ export default {
}, [])
}
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))
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 () {
statusColor() {
if (this.status == 0) {
return "rgba(255,0,0,0.8)"
return 'rgba(255,0,0,0.8)'
} else if (this.status == 1) {
return "rgba(255,100,0,0.8)"
return 'rgba(255,100,0,0.8)'
} else if (this.status == 2) {
return "rgba(0,255,0,0.5)"
return 'rgba(0,255,0,0.5)'
}
return ''
},
statusText () {
statusText() {
if (this.status == 0) {
return "Device not found"
return 'Device not found'
} else if (this.status == 1) {
return "Device found. Retrieving data..."
return 'Device found. Retrieving data...'
} else if (this.status == 2) {
return "Device connected"
return 'Device connected'
}
}
return ''
},
},
methods: {
markAsRead (val) {
let chatIndex = this.chats.findIndex(obj => obj.personId == val)
markAsRead(val) {
const chatIndex = this.chats.findIndex(obj => obj.personId == val)
if (chatIndex > -1) {
let chat = this.chats[chatIndex]
const chat = this.chats[chatIndex]
if (!chat.read) {
if (document.hasFocus()) {
chat.read = true
this.sendSocket({ action: 'markAsRead', data: { chatId: this.$route.params.id } })
this.sendSocket({
action: 'markAsRead',
data: { chatId: this.$route.params.id },
})
} else {
let onFocusHandler = () => {
if (!chat.read && this.$route.path == '/message/'+val) {
const onFocusHandler = () => {
if (!chat.read && this.$route.path == '/chat/' + val) {
chat.read = true
this.sendSocket({ action: 'markAsRead', data: { chatId: this.$route.params.id } })
this.sendSocket({
action: 'markAsRead',
data: { chatId: this.$route.params.id },
})
}
window.removeEventListener('focus', onFocusHandler)
}
window.addEventListener('focus', onFocusHandler)
}
}
}
},
closeWindow () {
closeWindow() {
this.win.close()
},
minimizeWindow () {
minimizeWindow() {
this.win.minimize()
},
maximizeWindow () {
maximizeWindow() {
this.maximizing = true
if (this.maximized) {
this.win.restore()
this.win.setSize(700,600)
this.win.setSize(700, 600)
this.win.center()
if (process.platform !== 'darwin') document.body.style.borderRadius = null
} else {
@ -180,28 +194,30 @@ export default {
this.maximizing = false
}, 50)
},
restart () {
restart() {
ipcRenderer.send('restart_app')
},
requestChats (clear) {
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}`} })
this.sendSocket({
action: 'fetchChats',
data: { offset: `${this.offset}`, limit: `${this.limit}` },
})
} else {
setTimeout(this.requestChats, 100)
}
},
connectWS () {
this.chats = []
connectWS() {
this.offset = 0
this.loading = false
this.$disconnect()
this.status = 0
this.chats = []
this.$store.commit('resetMessages')
this.$router.push('/').catch(()=>{})
this.$router.push('/').catch(() => {})
this.notifSound = new Audio(this.$store.state.notifSound)
const baseURI = this.$store.getters.baseURI
@ -210,22 +226,22 @@ export default {
reconnection: true,
})
},
deleteChat (chat) {
var chatIndex = this.chats.findIndex(obj => obj.personId == chat.personId)
deleteChat(chat) {
const chatIndex = this.chats.findIndex(obj => obj.personId == chat.personId)
if (chatIndex > -1) {
this.chats.splice(chatIndex, 1)
}
if (this.$route.path == '/message/'+chat.personId) {
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('/message/new')
composeMessage() {
if (this.status == 2) this.$router.push('/chat/new')
},
onMove (e) {
onMove(e) {
e.preventDefault()
if (this.maximizing) return
if (this.maximized) {
@ -234,25 +250,30 @@ export default {
this.maximized = false
}
},
cacheMessages () {
cacheMessages() {
if (!this.$store.state.cacheMessages) return
if (this.$socket && this.$socket.readyState == 1) {
for (let i = 0; i < this.chats.length; i++) {
let chat = this.chats[i]
this.sendSocket({ action: 'fetchMessages', data: {
const chat = this.chats[i]
this.sendSocket({
action: 'fetchMessages',
data: {
id: chat.personId,
offset: `0`,
limit: `25`
}
limit: `25`,
},
})
}
} else {
setTimeout(this.cacheMessages, 1000)
}
},
sendNotifierNotification (options, messageData, chatData) {
let tempDir = path.join(__dirname, '..', 'temp')
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)
}
@ -260,69 +281,60 @@ export default {
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)}`,
url: `${this.$store.getters.httpURI}/contactimg?docid=${messageData.authorDocid}&auth=${encodeURIComponent(
this.$store.state.password
)}`,
dest: fileDir,
agent: new require('https').Agent({ rejectUnauthorized: false })
agent: new require('https').Agent({ rejectUnauthorized: false }),
}
download.image(dlOptions).then((resp) => {
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, metadata)=> {
if (err) return
if ((action == 'activate' || action == undefined) && chatData && chatData.id) {
ipcRenderer.send('show_win')
this.$router.push('/message/'+messageData.personId)
}
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)
}
})
})
})
},
sendElectronNotification (options, messageData, chatData) {
const notification = {
title: options.title,
body: options.message,
silent: !options.sound
}
let notif = new remote.Notification(notification)
notif.on('click', (event, arg) => {
if (chatData && chatData.id) {
ipcRenderer.send('show_win')
this.$router.push('/message/'+messageData.personId)
}
})
notif.show()
}
},
beforeDestroy () {
beforeUnmount() {
$(window).off('resize')
this.win.removeListener('move', this.onMove)
ipcRenderer.removeAllListeners('win_id')
},
mounted () {
mounted() {
this.connectWS()
let container = this.$refs.chats.SimpleBar.getScrollElement()
container.addEventListener('scroll', (e) => {
const container = this.$refs.chats
container.addEventListener('scroll', () => {
if (container.scrollTop + container.offsetHeight == container.scrollHeight && !this.loading) {
this.requestChats()
}
@ -330,7 +342,7 @@ export default {
$(document).mousedown(event => {
if (event.which == 3) {
//this is a right click, so electron-context-menu will be appearing momentarily...
//this is a right click, so electron-context-menu will be appearing momentarily...
ipcRenderer.send('rightClickMessage', null)
}
})
@ -348,19 +360,19 @@ export default {
ipcRenderer.on('navigateTo', (sender, id) => {
if (isNaN(id)) {
this.$router.push('/message/new').catch(()=>{})
this.$router.push('/chat/new').catch(() => {})
} else {
let arrayId = parseInt(id) - 1
const arrayId = parseInt(id) - 1
if (this.chats[arrayId]) {
this.$router.push('/message/'+this.chats[arrayId].personId).catch(()=>{})
this.$router.push('/chat/' + this.chats[arrayId].personId).catch(() => {})
}
}
})
ipcRenderer.on('win_id', (e, id) => {
this.win = window.remote.BrowserWindow.fromId(id)
this.win = remote.BrowserWindow.fromId(id)
window.addEventListener('resize', (e) => {
window.addEventListener('resize', () => {
if (this.maximizing) return
if (this.maximized) {
this.win.restore()
@ -380,11 +392,11 @@ export default {
}
if (!this.$store.state.acceleration) {
document.documentElement.style.backgroundColor = "black"
document.documentElement.style.backgroundColor = 'black'
}
},
socket: {
fetchChats (data) {
fetchChats(data) {
if (this.offset == 0) this.chats = data
else this.chats.push(...data)
this.offset += this.limit
@ -392,16 +404,16 @@ export default {
this.cacheMessages()
this.status = 2
},
fetchMessages (data) {
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) {
var chatData = data.chat[0]
newMessage(data) {
const chatData = data.chat[0]
if (chatData && chatData.personId) {
var chatIndex = this.chats.findIndex(obj => obj.personId == chatData.personId)
const chatIndex = this.chats.findIndex(obj => obj.personId == chatData.personId)
if (chatIndex > -1) {
this.chats.splice(chatIndex, 1)
@ -410,15 +422,18 @@ export default {
this.chats.unshift(chatData)
}
var messageData = data.message[0]
const messageData = data.message[0]
if (messageData) {
this.$store.commit('setTyping', { chatId: messageData.personId, isTyping: false })
this.$store.commit('setTyping', {
chatId: messageData.personId,
isTyping: false,
})
if (this.$store.state.messagesCache[messageData.personId]) {
let oldMsgIndex = this.$store.state.messagesCache[messageData.personId].findIndex(obj => obj.guid == messageData.guid)
const oldMsgIndex = this.$store.state.messagesCache[messageData.personId].findIndex(obj => obj.guid == messageData.guid)
if (oldMsgIndex != -1) {
this.$set(this.$store.state.messagesCache[messageData.personId], oldMsgIndex, messageData)
this.$store.state.messagesCache[messageData.personId][oldMsgIndex] = messageData
return
}
this.$store.state.messagesCache[messageData.personId].unshift(messageData)
@ -429,94 +444,92 @@ export default {
if (this.lastNotificationGUID == messageData.guid) return
if (this.$route.params.id == messageData.personId && document.hasFocus()) return
let body = messageData.text.replace(/\u{fffc}/gu, "")
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
reply: true,
}
if (document.hasFocus()) return
// this.sendNotifierNotification(notificationOptions, messageData, chatData)
this.sendElectronNotification(notificationOptions, messageData, chatData)
this.sendNotifierNotification(notificationOptions, messageData, chatData)
} else if (messageData.sender != 1) {
console.log('Notifications are not supported on this system.')
}
}
},
newReaction (data) {
let reactions = data.reactions
newReaction(data) {
const reactions = data.reactions
if (reactions && reactions.length > 0 && this.$store.state.messagesCache[reactions[0].personId]) {
let msgIndex = this.$store.state.messagesCache[reactions[0].personId].findIndex(obj => obj.guid == reactions[0].forGUID)
const msgIndex = this.$store.state.messagesCache[reactions[0].personId].findIndex(obj => obj.guid == reactions[0].forGUID)
if (msgIndex > -1) {
this.$set(this.$store.state.messagesCache[reactions[0].personId][msgIndex], 'reactions', reactions)
this.$store.state.messagesCache[reactions[0].personId][msgIndex]['reactions'] = reactions
}
}
let chatData = data.chat[0]
const chatData = data.chat[0]
if (chatData && chatData.personId) {
let chatIndex = this.chats.findIndex(obj => obj.personId == 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()) {
let reaction = reactions[0]
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
message: reaction.text.replace(/\u{fffc}/gu, ''),
sound: this.$store.state.systemSound,
}
if (document.hasFocus()) return
// this.sendNotifierNotification(notificationOptions, reaction, chatData)
this.sendElectronNotification(notificationOptions, reaction, chatData)
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) {
setAsRead(data) {
if (this.$store.state.messagesCache[data.chatId]) {
let messageIndex = this.$store.state.messagesCache[data.chatId].findIndex(obj => obj.guid == data.guid)
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) {
setTypingIndicator(data) {
if (data && data.chat_id) {
let chatId = data.chat_id
let typing = (data.typing == true)
const chatId = data.chat_id
const typing = data.typing == true
if (chatId) this.$store.commit('setTyping', { chatId: chatId, isTyping: typing })
}
},
removeChat (data) {
removeChat(data) {
if (data.chatId) {
let chatId = data.chatId
var chatIndex = this.chats.findIndex(obj => obj.address == chatId)
const chatId = data.chatId
const chatIndex = this.chats.findIndex(obj => obj.address == chatId)
if (chatIndex > -1) {
let chat = this.chats[chatIndex]
if (this.$route.path == '/message/'+chat.personId) {
const chat = this.chats[chatIndex]
if (this.$route.path == '/chat/' + chat.personId) {
this.$router.push('/')
}
this.$store.state.messagesCache[chat.personId] = null
@ -524,28 +537,28 @@ export default {
}
}
},
onopen () {
onopen() {
this.offset = 0
this.requestChats()
this.status = 1
},
onerror () {
onerror() {
this.loading = false
if (this.$socket && this.$socket.readyState == 1) return
this.status = 1
this.$store.commit('resetMessages')
this.$router.push('/').catch(()=>{})
this.$router.push('/').catch(() => {})
this.chats = []
},
onclose (e) {
onclose() {
this.loading = false
if (this.$socket && this.$socket.readyState == 1) return
this.status = 0
this.$store.commit('resetMessages')
this.$router.push('/').catch(()=>{})
this.$router.push('/').catch(() => {})
this.chats = []
}
}
},
},
}
</script>
@ -560,8 +573,8 @@ export default {
.confirmDialog {
.vc-container {
background-color: rgba(45,45,45, 0.9);
border: 1px solid rgba(25,25,25,0.9);
background-color: rgba(45, 45, 45, 0.9);
border: 1px solid rgba(25, 25, 25, 0.9);
.vc-text {
color: white;
font-weight: 300;
@ -574,12 +587,12 @@ export default {
}
.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);
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);
background-color: rgba(45, 45, 45, 0.9);
filter: brightness(90%);
}
@ -590,10 +603,10 @@ export default {
}
.vue-popover {
background: rgba(25,25,25,0.8) !important;
background: rgba(25, 25, 25, 0.8) !important;
font-size: 14px;
&::before {
border-bottom-color: rgba(25,25,25,0.8) !important;
border-bottom-color: rgba(25, 25, 25, 0.8) !important;
}
}
@ -630,32 +643,48 @@ export default {
}
.textinput {
background-color: rgba(200,200,200,0.1);
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;
border-color: rgba(87, 87, 87, 0.7) !important;
color: #ebecec !important;
text-align: left !important;
}
.chats {
margin-top: 12px;
max-height: calc(100% - 80px);
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);
background-color: rgba(29, 29, 29, 0);
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, Avenir, Helvetica, Arial, sans-serif;
font-weight: 300;
}
@ -665,9 +694,9 @@ body {
height: calc(100% - 2px);
max-height: 100%;
width: calc(100% - 2px);
background-color: rgba(29,29,29, 0);
background-color: rgba(29, 29, 29, 0);
overflow: hidden;
border: 1px solid rgb(0,0,0);
border: 1px solid rgb(0, 0, 0);
border-radius: 10px;
}
@ -675,11 +704,14 @@ body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, Avenir, Helvetica, Arial, sans-serif;
font-weight: 300;
text-align: center;
color: #EBECEC;
color: #ebecec;
position: absolute;
top: 1px; left: 1px; right: 1px; bottom: 1px;
background-color: rgba(29,29,29, 0);
border: 1px solid #4A4A4A;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
background-color: rgba(29, 29, 29, 0);
border: 1px solid #4a4a4a;
border-radius: 10px;
&.maximized {
@ -695,7 +727,7 @@ body {
}
&.nostyle {
background-color: rgba(40,40,40,1);
background-color: rgba(40, 40, 40, 1);
border: none;
border-radius: 0;
height: 100%;
@ -709,38 +741,39 @@ body {
}
#content {
bottom: 0; right: 0; top: 0;
bottom: 0;
right: 0;
top: 0;
border-radius: 0;
}
}
}
.fade-enter {
opacity: 0;
}
.fade-enter-active {
transition: opacity 0.2s ease, backdrop-filter 0.2s ease;
}
.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);
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;
top: 0;
left: 0;
bottom: 0;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
&.notrans {
background-color: rgba(40,40,40,1);
background-color: rgba(40, 40, 40, 1);
}
a {
@ -752,7 +785,7 @@ body {
}
}
input:not([type="range"]):not([type="color"]):not(.message-input) {
input:not([type='range']):not([type='color']):not(.message-input) {
height: auto;
height: inherit;
font-size: 13px;
@ -765,10 +798,10 @@ body {
-webkit-app-region: no-drag;
}
input:not([type="range"]):not([type="color"]):not(.message-input):focus {
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 .3s;
animation: showFocus 0.3s;
border-color: rgb(122, 167, 221) !important;
}
}
@ -861,9 +894,12 @@ body {
#content {
float: left;
background-color: rgba(29,29,29, 1);
position:fixed;
top: 2px; left: 321px; right: 2px; bottom: 2px;
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;
@ -887,7 +923,7 @@ body {
}
}
input[type="search"] {
input[type='search'] {
text-indent: 0px;
text-align: center;
background-image: url('assets/search.svg');
@ -900,16 +936,16 @@ input[type="search"] {
border-radius: 5px !important;
}
input[type="search"]:focus {
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 {
input[type='search']::-webkit-input-placeholder {
/*text-align: center;*/
color: rgb(152,152,152);
color: rgb(152, 152, 152);
font-weight: 400;
letter-spacing: 0.2px;
}

View File

@ -2,90 +2,108 @@
import { app, protocol, BrowserWindow, ipcMain, Tray, Menu, dialog } from 'electron'
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'
import installExtension from 'electron-devtools-installer'
import path from 'path'
import contextMenu from 'electron-context-menu'
import { autoUpdater } from 'electron-updater'
import Store from 'electron-store'
import localShortcut from 'electron-localshortcut'
import AutoLaunch from 'auto-launch'
import axios from 'axios'
const isDevelopment = process.env.NODE_ENV !== 'production'
const path = require('path')
const contextMenu = require('electron-context-menu')
const { autoUpdater } = require('electron-updater')
const Store = require('electron-store')
const localShortcut = require('electron-localshortcut')
const AutoLaunch = require('auto-launch')
const isDevelopment = process.env.NODE_ENV !== 'production'
const persistentStore = new Store()
const autoLauncher = new AutoLaunch({
name: "WebMessage",
isHidden: true
name: 'WebMessage',
isHidden: true,
})
const startMinimized = (process.argv || []).indexOf('--hidden') !== -1
let tray = null
let win = null
let startMinimized = (process.argv || []).indexOf('--hidden') !== -1;
let rightClickedMessage = null
let tray: Nullable<Tray> = null
let win: Nullable<BrowserWindow> = null
let rightClickedMessage: Nullable<object> = null
let isQuitting = false
// Scheme must be registered before the app is ready
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { secure: true, standard: true } }
{
scheme: 'app',
privileges: {
secure: true,
standard: true,
},
},
])
contextMenu({
prepend: (defaultActions, params, browserWindow) => [
prepend: () => [
{
label: "Tapback",
label: 'Tapback',
visible: rightClickedMessage !== null,
click() {
win.webContents.send('reactToMessage', rightClickedMessage)
}
}
]
if (win) win.webContents.send('reactToMessage', rightClickedMessage)
},
},
],
})
async function loadURL() {
if (!win) return
if (process.env.WEBPACK_DEV_SERVER_URL) {
// Load the url of the dev server if in development mode
await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string)
if (!process.env.IS_TEST) win.webContents.openDevTools({ mode: 'undocked' })
// Log autoUpdater in development
// autoUpdater.logger = require('electron-log')
// autoUpdater.logger.transports.file.level = 'info'
} else {
createProtocol('app')
// Load the index.html when not in development
win.loadURL('app://./index.html')
}
}
async function createWindow() {
// Create the browser window.
win = new BrowserWindow({
width: 800,
width: 700,
height: 600,
minWidth: 700,
minHeight: 600,
transparent: persistentStore.get('acceleration', true) && (persistentStore.get('macstyle', true) || process.platform === 'darwin'),
transparent:
(persistentStore.get('acceleration', true) as boolean) &&
((persistentStore.get('macstyle', true) as boolean) || process.platform === 'darwin'),
frame: !(persistentStore.get('macstyle', true) || process.platform === 'darwin'),
fullscreenable: false,
maximizable: !(persistentStore.get('macstyle', true) || process.platform === 'darwin'),
useContentSize: true,
title: 'WebMessage',
webPreferences: {
// Use pluginOptions.nodeIntegration, leave this alone
// See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info
// nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
nodeIntegration: true,
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: (process.env.ELECTRON_NODE_INTEGRATION as unknown) as boolean,
enableRemoteModule: true,
devTools: isDevelopment && !process.env.IS_TEST,
spellcheck: true
spellcheck: true,
},
title: 'WebMessage'
})
win.setResizable(true)
if (process.platform !== 'darwin') {
win.removeMenu()
}
await loadURL()
loadURL()
// win.webContents.openDevTools({mode:'undocked'})
win.on('restore', () => {
showWin()
})
win.on('maximize', (e) => {
win.on('maximize', (e: Event) => {
e.preventDefault()
})
win.on('close', (e) => {
if (app.isQuitting || !persistentStore.get('minimize', true)) {
win.on('close', (e: Event) => {
if (isQuitting || !persistentStore.get('minimize', true)) {
app.quit()
} else {
} else if (win) {
e.preventDefault()
win.setSkipTaskbar(true)
win.hide()
@ -93,16 +111,16 @@ async function createWindow() {
}
})
win.webContents.on('did-fail-load', async() => {
win.webContents.on('did-fail-load', async () => {
loadURL()
})
autoUpdater.on('update-available', () => {
win.webContents.send('update_available')
if (win) win.webContents.send('update_available')
})
autoUpdater.on('update-downloaded', () => {
win.webContents.send('update_downloaded')
if (win) win.webContents.send('update_downloaded')
})
if (startMinimized) {
@ -113,57 +131,75 @@ async function createWindow() {
win.webContents.send('win_id', win.id)
win.webContents.session.on('will-download', (event, item, webContents) => {
win.webContents.session.on('will-download', (event, item) => {
event.preventDefault()
const fs = require('fs')
let extension = item.getFilename().split('.').pop()
let itemURL = item.getURL()
const extension = item
.getFilename()
.split('.')
.pop()
const itemURL = item.getURL()
dialog.showSaveDialog({ defaultPath: item.getFilename(), filters: [{ name: extension, extensions: [extension] }] }).then(({ canceled, filePath }) => {
if (canceled || !filePath) return
const writer = fs.createWriteStream(filePath)
dialog
.showSaveDialog({ defaultPath: item.getFilename(), filters: [{ name: extension as string, extensions: [extension as string] }] })
.then(({ canceled, filePath }) => {
if (canceled || !filePath) return
const writer = fs.createWriteStream(filePath)
const https = require('https')
axios({
method: 'GET',
url: itemURL,
responseType: 'stream',
httpsAgent: new require('https').Agent({ rejectUnauthorized: false })
}).then(({ data, headers }) => {
let totalBytes = headers['content-length']
let downloadedBytes = 0
win.setProgressBar(0.01)
axios({
method: 'GET',
url: itemURL,
responseType: 'stream',
httpsAgent: https.Agent({ rejectUnauthorized: false }),
}).then(({ data, headers }) => {
if (!win) return
const totalBytes = headers['content-length']
let downloadedBytes = 0
win.setProgressBar(0.01)
data.on('data', (chunk) => {
downloadedBytes += Buffer.byteLength(chunk)
win.setProgressBar(downloadedBytes / totalBytes)
data.on('data', (chunk: Buffer) => {
if (!win) return
downloadedBytes += Buffer.byteLength(chunk)
win.setProgressBar(downloadedBytes / totalBytes)
})
data.on('end', () => {
setTimeout(() => {
if (!win) return
win.setProgressBar(-1)
}, 500)
})
data.pipe(writer)
})
data.on('end', () => {
setTimeout(() => {
win.setProgressBar(-1)
}, 500)
})
data.pipe(writer)
})
})
})
}
async function loadURL () {
if (process.env.WEBPACK_DEV_SERVER_URL) {
// Load the url of the dev server if in development mode
await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
if (!process.env.IS_TEST) win.webContents.openDevTools({mode:'undocked'})
//Log autoUpdater in development
autoUpdater.logger = require("electron-log")
autoUpdater.logger.transports.file.level = "info"
} else {
createProtocol('app')
// Load the index.html when not in development
win.loadURL('app://./index.html')
function showWin() {
if (!win) createWindow()
else {
win.setSkipTaskbar(false)
win.show()
}
if (app.dock) app.dock.show()
if (win) {
win.on('restore', () => {
showWin()
})
}
}
function registerShortcuts() {
const keys = [1, 2, 3, 4, 5, 6, 7, 8, 9, 'N']
keys.forEach(key => {
localShortcut.register('CommandOrControl+' + key, () => {
if (win) win.webContents.send('navigateTo', key)
})
})
}
if (!persistentStore.get('acceleration', true)) {
@ -198,26 +234,20 @@ app.on('ready', async () => {
if (isDevelopment && !process.env.IS_TEST) {
// Install Vue Devtools
try {
await installExtension(VUEJS_DEVTOOLS)
await installExtension({
id: 'ljjemllljcmogpfapbkkighbhhppjdbg', //Vue Devtools beta
electron: '>=1.2.1',
})
} catch (e) {
console.error('Vue Devtools failed to install:', e.toString())
}
}
if (process.env.NODE_ENV !== 'production') {
// A directory named 'vender' in the project root
// is required for this to work. The correct file
// can be found within the vue-devtools project.
require('vue-devtools').install().catch((err) => {
console.log(err)
})
}
})
// Exit cleanly on request from parent process in development mode.
if (isDevelopment) {
if (process.platform === 'win32') {
process.on('message', (data) => {
process.on('message', data => {
if (data === 'graceful-exit') {
app.quit()
}
@ -231,9 +261,9 @@ if (isDevelopment) {
app.commandLine.appendSwitch('ignore-certificate-errors', 'true')
ipcMain.on('loaded', (event) => {
ipcMain.on('loaded', () => {
autoUpdater.checkForUpdatesAndNotify()
win.webContents.send('win_id', win.id)
if (win) win.webContents.send('win_id', win.id)
registerShortcuts()
})
@ -247,44 +277,48 @@ ipcMain.on('rightClickMessage', (event, args) => {
rightClickedMessage = args
})
ipcMain.on('app_version', (event) => {
ipcMain.on('app_version', event => {
event.sender.send('app_version', { version: app.getVersion() })
})
ipcMain.on('restart_app', () => {
setImmediate(() => {
app.isQuitting = true
isQuitting = true
autoUpdater.quitAndInstall()
})
})
ipcMain.on('startup_check', () => {
autoLauncher.isEnabled().then(function(isEnabled) {
let optionEnabled = persistentStore.get('startup', false)
autoLauncher
.isEnabled()
.then(function(isEnabled) {
const optionEnabled = persistentStore.get('startup', false)
if (optionEnabled && !isEnabled) {
autoLauncher.enable()
} else if (!optionEnabled && isEnabled) {
autoLauncher.disable()
}
}).catch(function (err) {
console.log("Could not detect autoLauncher settings:")
console.log(err)
})
if (optionEnabled && !isEnabled) {
autoLauncher.enable()
} else if (!optionEnabled && isEnabled) {
autoLauncher.disable()
}
})
.catch(function(err) {
console.log('Could not detect autoLauncher settings:')
console.log(err)
})
})
ipcMain.on('reload_app', () => {
app.isQuitting = true
isQuitting = true
app.relaunch()
app.quit()
})
ipcMain.on('quit_app', () => {
app.isQuitting = true
isQuitting = true
app.quit()
})
ipcMain.on('minimizeToTray', () => {
if (!win) return
win.setSkipTaskbar(true)
win.hide()
if (app.dock) app.dock.hide()
@ -294,54 +328,7 @@ ipcMain.on('show_win', () => {
showWin()
})
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', (event, commandLine, workingDirectory) => {
showWin()
})
app.whenReady().then(() => {
createWindow()
registerLocalAudioProtocol()
tray = new Tray(path.join(__static, 'trayicon.png'))
const contextMenu = Menu.buildFromTemplate([
{
label: 'Open WebMessage', click: () => {
showWin()
}
},
{
label: 'Quit', click: () => {
app.isQuitting = true
app.quit()
}
}
])
tray.on('double-click', () => {
showWin()
})
tray.setToolTip('WebMessage')
tray.setContextMenu(contextMenu)
})
}
function showWin () {
if (!win) createWindow()
else {
win.setSkipTaskbar(false)
win.show()
}
if (app.dock) app.dock.show()
}
function registerLocalAudioProtocol () {
function registerLocalFileProtocols() {
protocol.registerFileProtocol('wm-audio', (request, callback) => {
const url = request.url.replace(/^wm-audio:\/\//, '')
// Decode URL to prevent errors when loading filenames with UTF-8 chars or chars like "#"
@ -349,10 +336,7 @@ function registerLocalAudioProtocol () {
try {
return callback(path.join(__static, decodedUrl))
} catch (error) {
console.error(
'ERROR: registerLocalAudioProtocol: Could not get file path:',
error
)
console.error('ERROR: registerLocalAudioProtocol: Could not get file path:', error)
}
})
@ -363,22 +347,49 @@ function registerLocalAudioProtocol () {
try {
return callback(decodedUrl)
} catch (error) {
console.error(
'ERROR: registerLocalAudioProtocol: Could not get file path:',
error
)
console.error('ERROR: registerLocalAudioProtocol: Could not get file path:', error)
}
})
protocol.registerHttpProtocol('WebMessage', (request, callback) => {
})
// protocol.registerHttpProtocol('WebMessage', (request, callback) => {})
}
function registerShortcuts () {
[1,2,3,4,5,6,7,8,9,'N'].forEach((key) => {
localShortcut.register('CommandOrControl+'+key, () => {
win.webContents.send('navigateTo', key)
})
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', () => {
showWin()
})
}
app.whenReady().then(() => {
createWindow()
registerLocalFileProtocols()
tray = new Tray(path.join(__static, 'trayicon.png'))
const contextMenu = Menu.buildFromTemplate([
{
label: 'Open WebMessage',
click: () => {
showWin()
},
},
{
label: 'Quit',
click: () => {
isQuitting = true
app.quit()
},
},
])
tray.on('double-click', () => {
showWin()
})
tray.setToolTip('WebMessage')
tray.setContextMenu(contextMenu)
})
}

View File

@ -1,22 +1,37 @@
<template>
<audio class="audioplayer" controls width="100%" @canplay="handleLoad" @canplaythrough="handleLoad" @loadeddata="handleLoad"
:src="`${$store.getters.httpURI}/attachments?path=${encodeURIComponent(path)}&type=${encodeURIComponent(type)}&auth=${encodeURIComponent($store.state.password)}` + ($store.state.transcode ? '&transcode=1' : '')"
<audio
class="audioplayer"
controls
width="100%"
@canplay="handleLoad"
@canplaythrough="handleLoad"
@loadeddata="handleLoad"
:src="
`${$store.getters.httpURI}/attachments?path=${encodeURIComponent(path)}&type=${encodeURIComponent(type)}&auth=${encodeURIComponent(
$store.state.password
)}` + ($store.state.transcode ? '&transcode=1' : '')
"
/>
</template>
<script>
export default {
name: "AudioPlayer",
name: 'AudioPlayer',
props: {
path: { type: String },
type: { type: String },
loadedData: { type: Function }
path: {
type: String,
},
type: {
type: String,
},
loadedData: {
type: Function,
},
},
methods: {
handleLoad() {
this.$nextTick(this.loadedData)
}
},
},
}
</script>
@ -27,4 +42,4 @@ export default {
outline: none;
}
}
</style>
</style>

View File

@ -3,16 +3,16 @@
<template>
<div class="vue-avatar--wrapper" :style="[style, customStyle]" aria-hidden="true">
<!-- this img is not displayed; it is used to detect failure-to-load of div background image -->
<img v-if="this.isImage" class='avatar' :src="this.src" @error="onImgError"/>
<span v-show="!this.isImage">{{ userInitial }}</span>
<img v-if="isImage" class="avatar" :src="src" @error="onImgError" />
<span v-show="!isImage">{{ userInitial }}</span>
</div>
</template>
<script>
const getInitials = (username) => {
let parts = username.split(/[ -]/)
const getInitials = username => {
const parts = username.split(/[ -]/)
let initials = ''
for (var i = 0; i < parts.length; i++) {
for (let i = 0; i < parts.length; i++) {
initials += parts[i].charAt(0)
}
if (initials.length > 3 && initials.search(/[A-Z]/) !== -1) {
@ -25,86 +25,90 @@ export default {
name: 'avatar',
props: {
username: {
type: String
type: String,
},
initials: {
type: String
type: String,
},
color: {
type: String
type: String,
},
customStyle: {
type: Object
type: Object,
},
inline: {
type: Boolean
type: Boolean,
},
size: {
type: Number,
default: 50
default: 50,
},
src: {
type: String
type: String,
},
rounded: {
type: Boolean,
default: true
}
default: true,
},
},
data () {
data() {
return {
imgError: false
imgError: false,
}
},
mounted () {
emits: ['avatar-initials'],
mounted() {
if (!this.isImage) {
this.$emit('avatar-initials', this.username, this.userInitial)
}
},
computed: {
isImage () {
isImage() {
return !this.imgError && Boolean(this.src)
},
style () {
style() {
const style = {
display: this.inline ? 'inline-flex' : 'flex',
width: `${this.size}px`,
height: `${this.size}px`,
borderRadius: this.rounded ? '50%' : 0,
lineHeight: `${(this.size + Math.floor(this.size / 20)) - 1}px`,
lineHeight: `${this.size + Math.floor(this.size / 20) - 1}px`,
fontWeight: '500',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
userSelect: 'none',
background: 'linear-gradient(#6C6C6C, #474747)'
background: 'linear-gradient(#6C6C6C, #474747)',
}
return style
},
userInitial () {
userInitial() {
if (!this.isImage) {
const regex = /<% RGI_Emoji %>|\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Emoji_Modifier_Base}/gu
const variationSelector = /[\u180B-\u180D\uFE00-\uFE0F]|\uDB40[\uDD00-\uDDEF]/gu
let cleanName = this.username.replace(regex, '').replace(variationSelector, '').trim()
let cleanName = this.username
.replace(regex, '')
.replace(variationSelector, '')
.trim()
if (cleanName.includes(',')) {
cleanName = cleanName.replace(' ', '')
cleanName = cleanName.replace(',', ' ')
}
return getInitials(cleanName).substring(0, 2)
}
return ''
}
},
},
methods: {
initial: getInitials,
onImgError (evt) {
onImgError() {
this.imgError = true
}
}
},
},
}
</script>
<style lang="scss" scoped>
@ -113,4 +117,4 @@ export default {
max-width: 100%;
}
}
</style>
</style>

View File

@ -1,24 +1,37 @@
<template>
<div class="chatContainer" :class="$route.path == '/message/'+chatid ? 'active' : ''" :id="'id'+chatid" @click="navigate">
<div class="chatWrapper" @mousedown="dragMouseDown" :style="{ left: '-'+slideX+'px' }">
<confirm-dialog ref="deleteConfirmation" class="confirmDialog"></confirm-dialog>
<div class="chatContainer" :class="$route.path == '/chat/' + chatid ? 'active' : ''" :id="'id' + chatid" @click="navigate">
<div
class="chatWrapper"
@mousedown="dragMouseDown"
:style="{
left: '-' + slideX + 'px',
}"
>
<div class="unread" :style="read ? 'background-color: transparent;' : ''"></div>
<div class="avatarContainer">
<avatar v-if="(docid && docid != 0) || isGroup" class='avatar'
<avatar
v-if="(docid && docid != 0) || isGroup"
class="avatar"
:username="author"
:size="40"
inline
:src="`${$store.getters.httpURI}/contactimg?docid=${encodeURIComponent(isGroup ? chatid : docid)}&auth=${encodeURIComponent($store.state.password)}`"
:src="
`${$store.getters.httpURI}/contactimg?docid=${encodeURIComponent(isGroup ? chatid : docid)}&auth=${encodeURIComponent(
$store.state.password
)}`
"
/>
<img v-else class="avatar" src="../assets/profile.jpg" />
</div>
<div class="chatContent">
<div class="title">
<span class="author">
<span class="name" v-html="$options.filters.nativeEmoji(author)"></span>
<span class="name" v-html="$filters.nativeEmoji(author)"></span>
<span v-if="showNum" class="number"> ({{ chatid }})</span>
<feather type="bell-off" stroke="rgb(85,85,85)" size="13" v-if="$store.state.mutedChats.includes(chatid)"></feather>
</span>
<span class="date">{{ date/1000 | moment }}</span>
<span class="date">{{ momentDate }}</span>
</div>
<typing-indicator v-if="$store.state.isTyping[chatid]"></typing-indicator>
<div class="text" v-else>
@ -26,32 +39,64 @@
</div>
</div>
</div>
<div class="slidableDiv" :style="{ left: 'calc(100% - '+slideX+'px)' }">
<div class="hideAlerts" @click="hideAlerts">{{ $store.state.mutedChats.includes(chatid) ? 'Unhide Alerts' : 'Hide Alerts' }}</div>
<div class="deleteChat" @click="deleteChat">Delete</div>
</div>
<div
class="slidableDiv"
:style="{
left: 'calc(100% - ' + slideX + 'px)',
}"
>
<div class="hideAlerts" @click="hideAlerts">
{{ $store.state.mutedChats.includes(chatid) ? 'Unhide Alerts' : 'Hide Alerts' }}
</div>
<div class="deleteChat" @click="deleteChat">
Delete
</div>
</div>
</div>
</template>
<script>
import moment from 'moment'
import TypingIndicator from './TypingIndicator.vue'
import Avatar from './Avatar'
import TypingIndicator from '@/components/TypingIndicator.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import Avatar from '@/components/Avatar'
import sendSocketMixin from '@/mixin/socket'
export default {
components: { TypingIndicator, Avatar },
components: {
TypingIndicator,
Avatar,
ConfirmDialog,
},
name: 'Chat',
props: {
chatid: { type: String },
docid: { type: Number },
author: { type: String },
text: { type: String },
date: { type: Number },
read: { type: Boolean },
showNum: { type: Boolean },
isGroup: { type: Boolean }
chatid: {
type: String,
},
docid: {
type: Number,
},
author: {
type: String,
},
text: {
type: String,
},
date: {
type: Number,
},
read: {
type: Boolean,
},
showNum: {
type: Boolean,
},
isGroup: {
type: Boolean,
},
},
data () {
mixins: [sendSocketMixin],
data() {
return {
initialX: 0,
slideX: 0,
@ -59,63 +104,66 @@ export default {
sliderWidth: 120,
}
},
emits: ['navigated', 'deleted'],
computed: {
trimmedText() {
let text = this.$options.filters.nativeEmoji(this.text)
if (text == '' && this.text.includes("")) { //This looks empty, but there is a unicode character in there (U+FFFC)
return "<i>Attachment</i>"
const text = this.$filters.nativeEmoji(this.text)
if (text == '' && this.text.includes('')) {
//This looks empty, but there is a unicode character in there (U+FFFC)
return '<i>Attachment</i>'
}
return text
}
},
momentDate() {
return this.moment(this.date / 1000)
},
},
filters: {
moment: function (date) {
let ts = date*1000
let tsDate = new Date(ts).setHours(0,0,0,0)
methods: {
navigate() {
this.$emit('navigated')
if (new Date().setHours(0,0,0,0) == tsDate) {
if (this.$route.path != '/chat/' + this.chatid) {
this.$router.push('/chat/' + this.chatid)
}
},
moment(date) {
const ts = date * 1000
const tsDate = new Date(ts).setHours(0, 0, 0, 0)
if (new Date().setHours(0, 0, 0, 0) == tsDate) {
return moment(ts).format('LT')
} else if (tsDate >= new Date().setHours(0,0,0,0) - 86400000) {
} else if (tsDate >= new Date().setHours(0, 0, 0, 0) - 86400000) {
return 'Yesterday'
} else {
let today = new Date()
let lastWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7).setHours(0,0,0,0)
const today = new Date()
const lastWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7).setHours(0, 0, 0, 0)
if (ts >= lastWeek) {
return moment(ts).format('dddd')
}
}
return moment(ts).format("M/D/YY")
}
},
methods: {
navigate () {
this.$emit('navigated')
if (this.$route.path != '/message/'+this.chatid) {
this.$router.push('/message/'+this.chatid)
}
return moment(ts).format('M/D/YY')
},
dragMouseDown (e) {
dragMouseDown(e) {
e.preventDefault()
if (this.closingSlider) return
this.initialX = e.clientX
if (this.slideX > 0) {
this.closeDragElement()
}
document.onmousemove = this.elementDrag
document.onmouseup = this.finishDragElement
},
elementDrag (e) {
elementDrag(e) {
e.preventDefault()
if (this.closingSlider) return
if (this.initialX - e.clientX < 0 || this.initialX - e.clientX > this.sliderWidth) return
this.slideX = this.initialX - e.clientX
},
closeDragElement () {
closeDragElement() {
this.closingSlider = true
let interval = setInterval(() => {
const interval = setInterval(() => {
if (this.slideX <= 0) {
this.closingSlider = false
this.slideX = 0
@ -123,24 +171,24 @@ export default {
clearInterval(interval)
return
}
this.slideX -= 3
}, 6)
document.onmouseup = null
document.onmousemove = null
},
finishDragElement () {
finishDragElement() {
if (this.closingSlider) return
if (this.slideX >= (this.sliderWidth/2.2)) {
let interval = setInterval(() => {
if (this.slideX >= this.sliderWidth / 2.2) {
const interval = setInterval(() => {
if (this.closingSlider) return
if (this.slideX >= this.sliderWidth) {
this.slideX = this.sliderWidth
clearInterval(interval)
return
}
this.slideX += 3
}, 6)
} else {
@ -150,169 +198,174 @@ export default {
document.onmouseup = null
document.onmousemove = null
},
deleteChat () {
deleteChat() {
this.closeDragElement()
this.$confirm({
this.$refs.deleteConfirmation.open({
title: 'Warning',
message: `Are you sure you want to delete your messages from ${this.author}? This cannot be undone.`,
button: {
no: 'No',
yes: 'Yes'
yes: 'Yes',
},
callback: confirm => {
if (confirm) {
this.sendSocket({ action: 'deleteChat', data: { chatId: `${this.chatid}` } })
this.sendSocket({
action: 'deleteChat',
data: {
chatId: `${this.chatid}`,
},
})
this.$emit('deleted')
}
}
},
})
},
hideAlerts () {
hideAlerts() {
this.closeDragElement()
this.$store.commit(this.$store.state.mutedChats.includes(this.chatid) ? 'unmuteChat' : 'muteChat', this.chatid)
}
}
},
},
}
</script>
<style lang="scss">
.unread {
background-color: #0a84ff;
width: 9px;
height: 9px;
border-radius: 10px;
float: left;
margin-left: 2px;
margin-top: 26px;
}
.unread {
background-color: #0A84FF;
width: 9px;
height: 9px;
border-radius: 10px;
float: left;
margin-left: 2px;
margin-top: 26px;
.chatContent {
float: right;
// padding: 0px;
text-align: left;
height: calc(100% - 1px);
width: calc(100% - 65px);
border-bottom: 1px solid rgba(87, 87, 87, 0.7);
font-size: 13px;
position: relative;
}
.text {
color: rgb(157, 157, 157);
width: calc(100% - 12px);
margin-top: -3px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-align: start;
unicode-bidi: plaintext;
}
.title {
padding-top: 6px;
}
.author {
font-weight: 500;
max-width: 156px;
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.number {
font-weight: 200;
font-size: 11px;
}
.chatContent {
float: right;
// padding: 0px;
text-align: left;
height: calc(100% - 1px);
width: calc(100% - 65px);
border-bottom: 1px solid rgba(87,87,87,0.7);
font-size: 13px;
position: relative;
i {
padding-left: 4px;
vertical-align: middle;
}
}
.text {
color: rgb(157,157,157);
width: calc(100% - 12px);
margin-top: -3px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-align: start;
unicode-bidi: plaintext;
.date {
font-weight: 300;
padding-right: 10px;
float: right;
color: rgb(157, 157, 157);
}
.avatarContainer {
float: left;
width: 57px;
padding-left: 0;
margin-left: -4px;
padding-top: 11px;
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
}
.title {
padding-top: 6px;
}
.chatContainer {
width: 300px;
height: 64px;
border-radius: 5px;
position: relative;
overflow: hidden;
.author {
font-weight: 500;
max-width: 156px;
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.active {
background-color: #2284ff;
.number {
font-weight: 200;
font-size: 11px;
}
.chatContent {
border: none;
i {
padding-left: 4px;
vertical-align: middle;
}
}
.text {
color: #a9d5ff;
}
.date {
font-weight: 300;
padding-right: 10px;
float: right;
color: rgb(157,157,157);
}
.avatarContainer {
float: left;
width: 57px;
padding-left: 0;
margin-left: -4px;
padding-top: 11px;
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
}
.chatContainer {
width: 300px;
height: 64px;
border-radius: 5px;
position: relative;
overflow: hidden;
&.active {
background-color: #2284FF;
.chatContent {
border: none;
.text {
color: #A9D5FF;
}
.date {
color: #A9D5FF;
}
.date {
color: #a9d5ff;
}
}
}
.chatWrapper {
height: inherit;
position: absolute;
width: inherit;
}
.chatWrapper {
height: inherit;
position: absolute;
width: inherit;
}
.slidableDiv {
position: absolute;
left: 100%;
.slidableDiv {
position: absolute;
left: 100%;
display: flex;
width: 120px;
height: 100%;
div {
display: flex;
width: 120px;
height: 100%;
flex: 1 1 0px;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 400;
cursor: pointer;
div {
display: flex;
flex: 1 1 0px;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 400;
cursor: pointer;
&:hover {
filter: brightness(85%);
}
}
.deleteChat {
background: #FF3B2F;
}
.hideAlerts {
background: #5959D1;
&:hover {
filter: brightness(85%);
}
}
.deleteChat {
background: #ff3b2f;
}
.hideAlerts {
background: #5959d1;
}
}
}
</style>

View File

@ -0,0 +1,273 @@
<template>
<transition name="fade">
<div v-if="isShow" @click="handleClickOverlay" class="vc-overlay" id="vueConfirm">
<transition name="zoom">
<div v-if="isShow" ref="vueConfirmDialog" class="vc-container">
<span class="vc-text-grid">
<h4 v-if="dialog.title" class="vc-title">{{ dialog.title }}</h4>
<p v-if="dialog.message" class="vc-text">{{ dialog.message }}</p>
<span v-if="dialog.auth">
<input
v-model="password"
@keyup.enter="e => handleClickButton(e, true)"
class="vc-input"
type="password"
name="vc-password"
placeholder="Password"
autocomplete="off"
/>
</span>
</span>
<div class="vc-btn-grid" :class="{ isMono: !dialog.button.no || !dialog.button.yes }">
<button v-if="dialog.button.no" @click.stop="e => handleClickButton(e, false)" class="vc-btn left">
{{ dialog.button.no }}
</button>
<button
v-if="dialog.button.yes"
:disabled="dialog.auth ? !password : false"
@click.stop="e => handleClickButton(e, true)"
class="vc-btn"
>
{{ dialog.button.yes }}
</button>
</div>
</div>
</transition>
</div>
</transition>
</template>
<script>
const Component = {
name: 'ConfirmDialog',
data() {
return {
isShow: false,
password: null,
dialog: {
auth: false,
title: '',
message: '',
button: {},
},
params: {},
}
},
methods: {
resetState() {
this.password = null
this.dialog = {
auth: false,
title: '',
message: '',
button: {},
callback: () => {},
}
},
handleClickButton({ target }, confirm) {
if (target.id == 'vueConfirm') return
if (confirm && this.dialog.auth && !this.password) return
this.isShow = false
// callback
if (this.params.callback) {
this.params.callback(confirm, this.password)
}
},
handleClickOverlay({ target }) {
if (target.id == 'vueConfirm') {
this.isShow = false
// callback
if (this.params.callback) {
this.params.callback(false, this.password)
}
}
},
handleKeyUp({ keyCode }) {
if (keyCode == 27) {
this.handleClickOverlay({ target: { id: 'vueConfirm' } })
}
if (keyCode == 13) {
this.handleClickButton({ target: { id: '' } }, true)
}
},
open(params) {
this.resetState()
this.params = params
this.isShow = true
// set params to dialog state
Object.entries(params).forEach(param => {
if (typeof param[1] == typeof this.dialog[param[0]]) {
this.dialog[param[0]] = param[1]
}
})
},
},
}
export default Component
</script>
<style>
:root {
--title-color: black;
--message-color: black;
--overlay-background-color: #0000004a;
--container-box-shadow: #0000004a 0px 3px 8px 0px;
--base-background-color: #ffffff;
--button-color: #4083ff;
--button-background-color: #ffffff;
--button-border-color: #e0e0e0;
--button-background-color-disabled: #f5f5f5;
--button-background-color-hover: #f5f5f5;
--button-box-shadow-active: inset 0 2px 0px 0px #00000014;
--input-background-color: #ebebeb;
--input-background-color-hover: #dfdfdf;
--font-size-m: 16px;
--font-size-s: 14px;
--font-weight-black: 900;
--font-weight-bold: 700;
--font-weight-medium: 500;
--font-weight-normal: 400;
--font-weight-light: 300;
}
/**
* Dialog
*/
.vc-overlay *,
.vc-overlay *:before,
.vc-overlay *:after {
-webkit-box-sizing: border-box;
box-sizing: border-box;
text-decoration: none;
-webkit-touch-callout: none;
-moz-osx-font-smoothing: grayscale;
margin: 0;
padding: 0;
}
.vc-title {
color: var(--title-color);
padding: 0 1rem;
width: 100%;
font-weight: var(--font-weight-black);
text-align: center;
font-size: var(--font-size-m);
line-height: initial;
margin-bottom: 5px;
}
.vc-text {
color: var(--message-color);
padding: 0 1rem;
width: 100%;
font-weight: var(--font-weight-medium);
text-align: center;
font-size: var(--font-size-s);
line-height: initial;
}
.vc-overlay {
background-color: var(--overlay-background-color);
width: 100%;
height: 100%;
transition: all 0.1s ease-in;
left: 0;
top: 0;
z-index: 999999999999;
position: fixed;
display: flex;
justify-content: center;
align-items: center;
align-content: baseline;
}
.vc-container {
background-color: var(--base-background-color);
border-radius: 1rem;
width: 286px;
height: auto;
display: grid;
grid-template-rows: 1fr max-content;
box-shadow: var(--container-box-shadow);
}
.vc-text-grid {
padding: 1rem;
}
.vc-btn-grid {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
border-radius: 0 0 1rem 1rem;
overflow: hidden;
}
.vc-btn-grid.isMono {
grid-template-columns: 1fr;
}
.vc-btn {
border-radius: 0 0 1rem 0;
color: var(--button-color);
background-color: var(--button-background-color);
border: 0;
font-size: 1rem;
border-top: 1px solid var(--button-border-color);
cursor: pointer;
font-weight: var(--font-weight-bold);
outline: none;
min-height: 50px;
}
.vc-btn:hover {
background-color: var(--button-background-color-hover);
}
.vc-btn:disabled {
background-color: var(--button-background-color-disabled);
}
.vc-btn:active {
box-shadow: var(--button-box-shadow-active);
}
.vc-btn.left {
border-radius: 0;
border-right: 1px solid var(--button-border-color);
}
.vc-input[type='password'] {
width: 100%;
outline: none;
border-radius: 8px;
height: 35px;
border: 0;
margin: 5px 0;
background-color: var(--input-background-color);
padding: 0 0.5rem;
font-size: var(--font-size-m);
transition: 0.21s ease;
}
.vc-input[type='password']:hover,
.vc-input[type='password']:focus {
background-color: var(--input-background-color-hover);
}
/**
* Transition
*/
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.21s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.zoom-enter-active,
.zoom-leave-active {
animation-duration: 0.21s;
animation-fill-mode: both;
animation-name: zoom;
}
.zoom-leave-active {
animation-direction: reverse;
}
@keyframes zoom {
from {
opacity: 0;
transform: scale3d(1.1, 1.1, 1.1);
}
100% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
</style>

View File

@ -1,7 +1,8 @@
<template>
<div class="downloadContainer" @click="download">
<span class="file">
<span class="filename">{{ filename }}</span>.<span class="extension">{{ extension }}</span>
<span class="filename">{{ filename }}</span
>.<span class="extension">{{ extension }}</span>
</span>
<feather type="download" stroke="#c2c2c2" size="20"></feather>
</div>
@ -10,37 +11,51 @@
<script>
export default {
props: {
path: { type: String },
type: { type: String }
path: {
type: String,
},
type: {
type: String,
},
},
data () {
return {
}
data() {
return {}
},
computed: {
url() {
return `${this.$store.getters.httpURI}/attachments?path=${encodeURIComponent(this.path)}&type=${encodeURIComponent(this.type)}&auth=${encodeURIComponent(this.$store.state.password)}`
return `${this.$store.getters.httpURI}/attachments?path=${encodeURIComponent(this.path)}&type=${encodeURIComponent(
this.type
)}&auth=${encodeURIComponent(this.$store.state.password)}`
},
file () {
file() {
return this.path.split('/').pop()
},
filename () {
return this.path.split('/').pop().split('.').slice(0, -1).join('.')
filename() {
return this.path
.split('/')
.pop()
.split('.')
.slice(0, -1)
.join('.')
},
extension() {
return this.path
.split('/')
.pop()
.split('.')
.pop()
},
extension () {
return this.path.split('/').pop().split('.').pop()
}
},
methods: {
download () {
let a = document.createElement('a')
download() {
const a = document.createElement('a')
a.download = this.type
a.href = this.url
a.href = this.url
document.body.appendChild(a)
a.click()
a.remove()
}
}
},
},
}
</script>
@ -51,7 +66,7 @@ export default {
color: lighten(#c2c2c2, 20%);
max-width: 100%;
width: auto;
background-color: #3A3A3C;
background-color: #3a3a3c;
position: relative;
overflow-wrap: break-word;
@ -93,4 +108,4 @@ export default {
}
}
}
</style>
</style>

View File

@ -1,5 +1,12 @@
<template>
<div class="expandable-image" :class="{ expanded: expanded, nostyle: $store.state.macstyle }" ref="this">
<div
class="expandable-image"
:class="{
expanded: expanded,
nostyle: $store.state.macstyle,
}"
ref="this"
>
<template v-if="loadedImage">
<i v-if="!expanded" class="expand-button" @click="expandImage">
<feather type="maximize-2" stroke="#fff" size="24"></feather>
@ -8,37 +15,50 @@
<feather type="download" stroke="#fff" size="24"></feather>
</i>
</template>
<v-lazy-image crossorigin="anonymous" :src="url" @load="handleLoad" :download="path.split('/').pop()"/>
<lazy-image crossorigin="anonymous" :id="guid" :src="url" @load="handleLoad" :download="path.split('/').pop()" />
</div>
</template>
<script>
export default {
props: {
path: { type: String },
type: { type: String },
loadedData: { type: Function }
path: {
type: String,
},
type: {
type: String,
},
loadedData: {
type: Function,
},
guid: {
type: String,
},
},
data () {
data() {
return {
expanded: false,
loadedImage: false,
cloned: null
cloned: null,
}
},
computed: {
url() {
return `${this.$store.getters.httpURI}/attachments?path=${encodeURIComponent(this.path)}&type=${encodeURIComponent(this.type)}&auth=${encodeURIComponent(this.$store.state.password)}` + (this.$store.state.transcode ? '&transcode=1' : '')
}
return (
`${this.$store.getters.httpURI}/attachments?path=${encodeURIComponent(this.path)}&type=${encodeURIComponent(
this.type
)}&auth=${encodeURIComponent(this.$store.state.password)}` + (this.$store.state.transcode ? '&transcode=1' : '')
)
},
},
mounted() {
document.addEventListener('keydown', this.closeImage)
},
beforeDestroy () {
beforeUnmount() {
document.removeEventListener('keydown', this.closeImage)
},
methods: {
closeImage (event) {
closeImage(event) {
event.stopPropagation()
if (!this.cloned) return
@ -55,35 +75,35 @@ export default {
},
expandImage() {
this.expanded = true
this.$nextTick(() => {
this.cloned = this.$el.cloneNode(true)
document.body.appendChild(this.cloned)
$(this.cloned).addClass('expanded')
this.cloned.addEventListener('touchmove', this.freezeVp, false)
this.cloned.addEventListener('click', this.onExpandedImageClick)
this.$nextTick(() => this.cloned.style.opacity = 1)
setTimeout(() => (this.cloned.style.opacity = 1), 10)
})
},
freezeVp (e) {
freezeVp(e) {
e.preventDefault()
},
onExpandedImageClick (e) {
onExpandedImageClick(e) {
this.closeImage(e)
},
download () {
let a = document.createElement('a')
download() {
const a = document.createElement('a')
a.download = this.type
a.href = this.url
document.body.appendChild(a)
a.click()
a.remove()
},
handleLoad () {
handleLoad() {
this.$nextTick(this.loadedData)
this.loadedImage = true
}
}
},
},
}
</script>
@ -92,7 +112,7 @@ export default {
position: relative;
transition: 0.25s opacity;
&.nostyle {
&.nostyle {
border-radius: 10px;
}
}
@ -117,10 +137,12 @@ body > .expandable-image.expanded > img {
object-fit: contain;
margin: 0 auto;
}
.expand-button .feather, .download-button .feather {
filter: drop-shadow(1px 1px 1px rgba(0,0,0,0.5));
.expand-button .feather,
.download-button .feather {
filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.5));
}
.expand-button, .download-button {
.expand-button,
.download-button {
position: absolute;
z-index: 999;
right: 10px;
@ -136,11 +158,12 @@ body > .expandable-image.expanded > img {
top: auto;
bottom: 10px;
}
.expandable-image:hover .expand-button, .expandable-image:hover .download-button {
.expandable-image:hover .expand-button,
.expandable-image:hover .download-button {
opacity: 1;
cursor: pointer;
}
.expandable-image img {
width: 100%;
}
</style>
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,59 +1,77 @@
<template>
<div class='URLContainer'>
<div class='bigImage' v-if="large && icon" @click="openURLFromImage">
<div class="URLContainer" v-if="!showNormally">
<div class="bigImage" v-if="large && icon" @click="openURLFromImage">
<img :src="icon" @click="showEmbed = true" @load="handleLoad" />
<iframe v-if="embed && showEmbed" :src="embed" frameborder="0"></iframe>
<video v-else-if="video && showEmbed" controls width="100%" @keypress.esc="escape" ref="videoPlayer">
<source :src="video"
type="video/mp4" />
This video type is not supported.
<source :src="video" type="video/mp4" />
This video type is not supported.
</video>
<div class='playIcon' v-if="!showEmbed && (embed || video)" @click="showEmbed = true">
<div class="playIcon" v-if="!showEmbed && (embed || video)" @click="showEmbed = true">
<feather type="play" stroke="rgb(200,200,200)" size="24"></feather>
</div>
</div>
<div class='URLInfo' :class="{ large: large }" @click="openURL">
<div class='URLText'>
<div class='URLTitle' v-if="title">
<div
class="URLInfo"
:class="{
large: large,
}"
@click="openURL"
>
<div class="URLText">
<div class="URLTitle" v-if="title">
{{ title }}
</div>
<div class='URLSubtitle'>
<div class="URLSubtitle">
{{ subtitle }}
</div>
</div>
<div class='URLIcon'>
<div class="URLIcon">
<img v-if="!large && icon" :src="icon" @load="handleLoad" />
<feather v-else type="chevron-right" stroke="#A7A7A7" size="16"></feather>
</div>
</div>
</div>
<div class="URLContainer normally" v-else>
{{ link }}
</div>
</template>
<script>
import { remote } from 'electron'
export default {
name: "PayloadAttachment",
name: 'PayloadAttachment',
props: {
loadedData: { type: Function },
payloadData: { type: Object }
loadedData: {
type: Function,
},
payloadData: {
type: Object,
},
date: {
type: Number,
},
},
emits: ['refreshRequest'],
data() {
return {
icon: null,
url: null,
title: null,
subtitle: null,
large: false,
embed: null,
link: '',
showEmbed: false,
vide: null,
video: null,
showNormally: false,
}
},
watch: {
payloadData() {
this.populateValues()
}
},
},
mounted() {
this.populateValues()
@ -62,11 +80,20 @@ export default {
populateValues() {
this.icon = this.payloadData.LPIconMetadata ? this.payloadData.LPIconMetadata[0] : null
// if (!this.icon) this.icon = this.payloadData.RichLinkImageAttachmentSubstitute ? this.payloadData.RichLinkImageAttachmentSubstitute[0] : null
this.large = this.payloadData.LPImageMetadata != null || (this.payloadData.LPIconMetadata && this.payloadData.LPIconMetadata[1] != '{0, 0}')
this.large =
this.payloadData.LPImageMetadata != null || (this.payloadData.LPIconMetadata && this.payloadData.LPIconMetadata[1] != '{0, 0}')
this.title = this.payloadData.NSURL ? this.payloadData.NSURL[1] : null
this.subtitle = this.payloadData.root[0].replace('http://', '').replace('https://', '').replace('www.', '').split('/')[0].toLowerCase()
this.embed = (this.payloadData.LPImageMetadata && this.payloadData.LPVideo && this.payloadData.LPVideo[0] == 'text/html') ? this.payloadData.LPImageMetadata[0] : null
this.subtitle = this.payloadData.root[0]
.replace('http://', '')
.replace('https://', '')
.replace('www.', '')
.split('/')[0]
.toLowerCase()
this.embed =
this.payloadData.LPImageMetadata && this.payloadData.LPVideo && this.payloadData.LPVideo[0] == 'text/html'
? this.payloadData.LPImageMetadata[0]
: null
this.link = this.payloadData.root[0]
this.video = this.payloadData.RichLinkVideoAttachmentSubstitute ? this.payloadData.RichLinkVideoAttachmentSubstitute[0] : null
@ -74,14 +101,25 @@ export default {
this.title = this.title.substring(0, 82).trim() + ' ...'
}
if (!this.title && this.payloadData.LPArtworkMetadata) {
this.title = this.payloadData.NSURL[0]
this.large = this.payloadData.RichLinkImageAttachmentSubstitute && this.payloadData.RichLinkImageAttachmentSubstitute[4] != null
this.icon = this.payloadData.RichLinkImageAttachmentSubstitute ? this.payloadData.RichLinkImageAttachmentSubstitute[4] : ''
}
if (!this.title) {
setTimeout(() => {
this.$emit('refreshRequest')
}, 1000)
// 15 seconds with no data, bad
if (Date.now() - this.date >= 15000) {
this.showNormally = true
} else {
setTimeout(() => {
this.$emit('refreshRequest')
}, 1000)
}
}
},
openURL() {
shell.openExternal(this.link)
remote.shell.openExternal(this.link)
},
openURLFromImage() {
if (this.embed || this.video) return
@ -94,7 +132,7 @@ export default {
if (e.key == 'Escape' && this.$refs.videoPlayer && document.fullscreenElement !== null) {
document.exitFullscreen()
}
}
},
},
}
</script>
@ -119,7 +157,8 @@ export default {
border-bottom-right-radius: 0;
}
iframe, video {
iframe,
video {
width: 100%;
height: 100%;
position: absolute;
@ -144,19 +183,19 @@ export default {
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(25,25,25,0.4);
background: rgba(25, 25, 25, 0.4);
display: flex;
align-items: center;
justify-content: center;
/deep/ .feather {
>>> .feather {
margin-left: 1px;
}
}
}
.URLInfo {
background: #3B3B3D;
background: #3b3b3d;
border-radius: 14px;
padding: 8px 15px;
padding-right: 10px;
@ -178,7 +217,7 @@ export default {
margin-bottom: 2px;
}
.URLSubtitle {
color: #A7A7A7;
color: #a7a7a7;
}
}
@ -187,11 +226,18 @@ export default {
flex: 1;
align-items: center;
justify-content: flex-end;
float: right;
img {
width: 32px;
}
}
}
&.normally {
color: #2284ff;
text-decoration: underline;
padding: 6px 10px;
}
}
</style>
</style>

View File

@ -0,0 +1,235 @@
<template>
<transition name="slide-fade" mode="out-in">
<div
class="reactions"
v-if="reactionList.length > 0"
:class="{
left: !targetFromMe,
}"
>
<div
v-for="(reaction, i) in reactionList"
@click="click"
:key="reaction.guid"
class="bubble"
:class="{
isMe: reaction.sender == 1,
}"
:style="{
zIndex: 4 - i,
top: position.top + 'px',
left: position.left ? position.left + (targetFromMe ? -i * 3 : i * 3) + 'px' : null,
right: position.right ? position.right + (targetFromMe ? i * 3 : -i * 3) + 'px' : null,
}"
>
<font-awesome-icon
v-if="reaction.icon && reaction.icon.name != 'exclamation'"
:icon="reaction.icon.name"
size="lg"
:style="reaction.icon.style"
/>
<font-awesome-layers v-else-if="reaction.icon.name == 'exclamation'" class="fa-1x">
<font-awesome-icon :icon="reaction.icon.name" size="1x" transform="left-3 down-1 shrink-1 rotate-355" />
<font-awesome-icon :icon="reaction.icon.name" size="1x" transform="right-3 rotate-4" />
</font-awesome-layers>
</div>
</div>
</transition>
</template>
<script>
export default {
name: 'ReactionBubbles',
props: {
reactions: {
type: Array,
},
targetFromMe: {
type: Boolean,
},
target: {
type: String,
},
part: {
type: Number,
},
click: {
type: Function,
},
balloon: {
type: Boolean,
default: false,
},
},
computed: {
reactionList() {
if (!this.reactions) return []
const reactions = this.reactions.filter(
reaction => reaction.reactionType >= 2000 && reaction.reactionType < 3000 && reaction.forPart == (this.balloon ? 'b' : this.part)
)
const reactionFromMe = null
reactions.forEach(reaction => {
const reactionId = reaction.reactionType
const icon = this.icons.find(icon => icon.id == reactionId)
reaction.icon = icon
// if (reaction.sender == 1) reactionFromMe = reaction
})
return (reactionFromMe ? reactions.slice(0, 3) : reactions.slice(0, 4)).sort((a, b) => (b.date - a.date > 0 ? 1 : -1))
},
},
watch: {
reactions() {
this.$nextTick(this.adjustPostion())
},
},
data() {
return {
icons: [
{
name: 'heart',
id: 2000,
style: {
color: '#FA5C99',
},
},
{
name: 'thumbs-up',
id: 2001,
},
{
name: 'thumbs-down',
id: 2002,
style: {
transform: 'scaleX(-1)',
},
},
{
name: 'laugh-squint',
id: 2003,
},
{
name: 'exclamation',
id: 2004,
},
{
name: 'question',
id: 2005,
},
],
position: {
top: 3,
left: 0,
},
}
},
mounted() {
this.$nextTick(this.adjustPostion())
setTimeout(this.adjustPostion(), 10) //i hate racey bois
window.addEventListener('resize', this.adjustPostion())
},
beforeMount() {
this.adjustPostion()
},
methods: {
adjustPostion() {
this.position.left = null
this.position.right = -18
if (this.targetFromMe) {
this.position.right = null
this.position.left = -18
}
},
},
}
</script>
<style lang="scss" scoped>
.reactions {
position: relative;
// margin-top: 18px;
height: 18px;
width: 100%;
&.left {
.bubble {
&::before,
&::after {
right: 0px;
left: auto;
}
&::after {
right: -4px;
}
}
}
.bubble {
background-color: #3a3a3c;
padding: 6px;
position: absolute;
border: solid 1px #1d1d1d;
border-radius: 50%;
width: 14px;
height: 14px;
line-height: 12px;
// transition: 0.3s;
&::before {
content: ' ';
width: 8px;
height: 8px;
background-color: #3a3a3c;
border-radius: 50%;
position: absolute;
bottom: -1px;
left: 0px;
}
&::after {
content: ' ';
width: 4px;
height: 4px;
background-color: #3a3a3c;
border-radius: 50%;
position: absolute;
bottom: -4px;
left: -4px;
}
svg {
width: 100%;
height: 100%;
}
.fa-layers {
height: 1.1em;
width: 1.1em;
}
&.isMe {
background-color: #1287ff;
&::before,
&::after {
background-color: #1287ff;
}
}
}
}
.slide-fade-enter-active {
transition: all 0.2s ease;
}
.slide-fade-leave-active {
transition: all 0.2s ease;
}
.slide-fade-enter,
.slide-fade-leave-to {
transform: scaleY(0);
margin-top: 0px;
height: 0px;
opacity: 0;
}
</style>

View File

@ -1,49 +1,111 @@
<template>
<transition name="fade" mode="out-in">
<div id="reactionMenu" v-if="target">
<div class="backdrop" @click="closeMenu">
</div>
<div class="menu" :style="{ top: position.top, left: position.left, right: position.right, bottom: position.bottom }" :class=" direction ">
<div class="backdrop" @click="closeMenu"></div>
<div
class="menu"
:style="{
top: position.top,
left: position.left,
right: position.right,
bottom: position.bottom,
}"
:class="direction"
>
<template v-for="icon in icons">
<font-awesome-icon v-if="icon.name != 'exclamation'" :key="icon.name" @click="sendReaction(icon)" :icon="icon.name" :class="{ active: activeReactions.includes(icon.id) }" :style="icon.style" size="lg" />
<font-awesome-layers v-else class="fa-lg" :key="icon.name" @click="sendReaction(icon)" :class="{ active: activeReactions.includes(icon.id) }">
<font-awesome-icon
v-if="icon.name != 'exclamation'"
:key="icon.name"
@click="sendReaction(icon)"
:icon="icon.name"
:class="{
active: activeReactions.includes(icon.id),
}"
:style="icon.style"
size="lg"
/>
<font-awesome-layers
v-else
class="fa-lg"
:key="icon.name"
@click="sendReaction(icon)"
:class="{
active: activeReactions.includes(icon.id),
}"
>
<font-awesome-icon :icon="icon.name" size="lg" transform="left-3 down-1 shrink-1 rotate-355" />
<font-awesome-icon :icon="icon.name" size="lg" transform="right-3 rotate-4" />
</font-awesome-layers>
</template>
</div>
</div>
</transition>
</template>
<script>
export default {
name: "ReactionMenu",
name: 'ReactionMenu',
emits: ['sendReaction', 'close'],
data() {
return {
icons: [
{ name: 'heart', id: 2000, style: { color: '#FA5C99' } },
{ name: 'thumbs-up', id: 2001 },
{ name: 'thumbs-down', id: 2002, style: { transform: 'scaleX(-1)'} },
{ name: 'laugh-squint', id: 2003 },
{ name: 'exclamation', id: 2004 },
{ name: 'question', id: 2005 },
{
name: 'heart',
id: 2000,
style: {
color: '#FA5C99',
},
},
{
name: 'thumbs-up',
id: 2001,
},
{
name: 'thumbs-down',
id: 2002,
style: {
transform: 'scaleX(-1)',
},
},
{
name: 'laugh-squint',
id: 2003,
},
{
name: 'exclamation',
id: 2004,
},
{
name: 'question',
id: 2005,
},
],
position: { top: '50px', left: '20px' },
position: {
top: '50px',
left: '20px',
},
direction: 'right',
clone: null,
activeReactions: []
activeReactions: [],
}
},
props: {
target: { type: Object },
guid: { type: String },
part: { type: Number },
reactions: { type: Array },
balloon: { type: Boolean, default: false }
target: {
type: Object,
},
guid: {
type: String,
},
part: {
type: Number,
},
reactions: {
type: Array,
},
balloon: {
type: Boolean,
default: false,
},
},
watch: {
target(newTarget) {
@ -54,34 +116,39 @@ export default {
} else {
this.activeReactions = []
}
}
},
},
methods: {
adjustPostion () {
let newTarget = this.target
adjustPostion() {
const newTarget = this.target
if (newTarget == null) return
let target = newTarget.children().last()
let parent = newTarget.parent().parent().parent()
let p = target.offset()
let w = target.width()
let h = target.height()
let clone = target.parent().clone()
const target = newTarget.children().last()
const parent = newTarget
.parent()
.parent()
.parent()
const p = target.offset()
// const w = target.width()
// const h = target.height()
const clone = target.parent().clone()
clone.css({
position: 'fixed',
top: p.top+'px',
left: p.left+'px',
width: target.parent().width()+'px',
height: target.parent().height()+'px',
margin: '0'
top: p.top + 'px',
left: p.left + 'px',
width: target.parent().width() + 'px',
height: target.parent().height() + 'px',
margin: '0',
})
let reactionsBubble = clone.find('.reactions')
const reactionsBubble = clone.find('.reactions')
if (reactionsBubble) reactionsBubble.remove()
let msgWrapper = $('<div class="messages" style="display:contents;z-index:-1;"></div>')
$('<div></div>').attr('class', parent.attr('class')).appendTo(msgWrapper)
const msgWrapper = $('<div class="messages" style="display:contents;z-index:-1;"></div>')
$('<div></div>')
.attr('class', parent.attr('class'))
.appendTo(msgWrapper)
clone.appendTo(msgWrapper.children().first())
this.$nextTick(() => {
msgWrapper.appendTo('#reactionMenu')
@ -93,28 +160,34 @@ export default {
}
this.clone = msgWrapper
this.direction = parent.hasClass('send') ? 'right' : 'left'
this.position = { }
this.position.top = (p.top - 45) + 'px'
this.position = {}
this.position.top = p.top - 45 + 'px'
if (this.direction == 'right') {
this.position.right = 6 + 'px'
} else {
this.position.left = (p.left - 15) + 'px'
this.position.left = p.left - 15 + 'px'
}
let activeReactionsList = this.reactions.filter(reaction => reaction.reactionType >= 2000 && reaction.reactionType < 3000 && reaction.sender == 1 && reaction.forPart == (this.balloon ? 'b' : this.part))
const activeReactionsList = this.reactions.filter(
reaction =>
reaction.reactionType >= 2000 &&
reaction.reactionType < 3000 &&
reaction.sender == 1 &&
reaction.forPart == (this.balloon ? 'b' : this.part)
)
this.activeReactions = []
activeReactionsList.forEach((reaction) => {
activeReactionsList.forEach(reaction => {
this.activeReactions.push(reaction.reactionType)
})
},
sendReaction (icon) {
sendReaction(icon) {
let id = icon.id
if (this.activeReactions.includes(id)) id += 1000 //Remove reaction
this.$emit('sendReaction', id, this.guid, this.part)
this.closeMenu()
},
closeMenu () {
closeMenu() {
setTimeout(() => {
if (this.clone) {
this.clone.remove()
@ -124,20 +197,20 @@ export default {
}, 200)
this.$emit('close')
}
},
},
socket: {
newMessage () {
newMessage() {
this.$nextTick(() => {
this.adjustPostion()
})
},
setTypingIndicator () {
setTypingIndicator() {
this.$nextTick(() => {
this.adjustPostion()
})
}
}
},
},
}
</script>
@ -148,10 +221,11 @@ export default {
left: 0;
right: 0;
bottom: 0;
z-index: 1;
z-index: 5;
.message.last:after {
background: #090909;
z-index: 1;
}
.backdrop {
@ -160,16 +234,16 @@ export default {
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.6);
background: rgba(0, 0, 0, 0.6);
z-index: -1;
}
.menu {
position: fixed;
background-color: rgb(70,70,70);
background-color: rgb(70, 70, 70);
padding: 5px 10px;
border-radius: 20px;
color: rgb(140,140,140);
color: rgb(140, 140, 140);
z-index: 1;
& > svg {
@ -183,11 +257,11 @@ export default {
cursor: pointer;
&:hover {
background-color: rgba(0,0,0,0.2);
background-color: rgba(0, 0, 0, 0.2);
}
&.active {
background-color: #2484FF;
background-color: #2484ff;
color: white;
}
}
@ -208,35 +282,35 @@ export default {
}
&:hover {
background-color: rgba(0,0,0,0.2);
background-color: rgba(0, 0, 0, 0.2);
}
&.active {
background-color: #2484FF;
background-color: #2484ff;
color: white;
}
}
&:after {
content: "";
content: '';
position: absolute;
bottom: -5px;
right: 11px;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgb(70,70,70);
background: rgb(70, 70, 70);
}
&:before {
content: "";
content: '';
position: absolute;
bottom: -11px;
right: 9px;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgb(70,70,70);
background: rgb(70, 70, 70);
}
&.left {
@ -252,4 +326,4 @@ export default {
}
}
}
</style>
</style>

View File

@ -1,171 +0,0 @@
<template>
<transition name="slide-fade" mode="out-in">
<div class="reactions" v-if="reactionList.length > 0" :class="{ left: !targetFromMe }">
<div v-for="(reaction, i) in reactionList" @click="click" :key="reaction.guid"
class="bubble" :class="{ isMe: reaction.sender == 1 }"
:style="{
zIndex: 4-i, top: position.top+'px',
left: position.left ? ((position.left+(targetFromMe ? (-i*3) : (i*3)))+'px') : null,
right: position.right ? ((position.right+(targetFromMe ? (i*3) : (-i*3)))+'px') : null
}">
<font-awesome-icon v-if="reaction.icon && reaction.icon.name != 'exclamation'" :icon="reaction.icon.name" size="lg" :style="reaction.icon.style" />
<font-awesome-layers v-else-if="reaction.icon.name == 'exclamation'" class="fa-1x">
<font-awesome-icon :icon="reaction.icon.name" size="1x" transform="left-3 down-1 shrink-1 rotate-355" />
<font-awesome-icon :icon="reaction.icon.name" size="1x" transform="right-3 rotate-4" />
</font-awesome-layers>
</div>
</div>
</transition>
</template>
<script>
export default {
name: "Reactions",
props: {
reactions: { type: Array },
targetFromMe: { type: Boolean },
target: { type: String },
part: { type: Number },
click: { type: Function },
balloon: { type: Boolean, default: false },
},
computed: {
reactionList() {
if (!this.reactions) return []
let reactions = this.reactions.filter(reaction => reaction.reactionType >= 2000 && reaction.reactionType < 3000 && reaction.forPart == (this.balloon ? 'b' : this.part))
let reactionFromMe = null
reactions.forEach((reaction) => {
let reactionId = reaction.reactionType
let icon = this.icons.find(icon => icon.id == reactionId)
reaction.icon = icon
// if (reaction.sender == 1) reactionFromMe = reaction
})
return (reactionFromMe ? reactions.slice(0, 3) : reactions.slice(0, 4)).sort((a, b) => (b.date - a.date > 0 ? 1 : -1))
}
},
watch: {
reactions(newVal) {
this.$nextTick(this.adjustPostion())
}
},
data() {
return {
icons: [
{ name: 'heart', id: 2000, style: { color: '#FA5C99' } },
{ name: 'thumbs-up', id: 2001 },
{ name: 'thumbs-down', id: 2002, style: { transform: 'scaleX(-1)'} },
{ name: 'laugh-squint', id: 2003 },
{ name: 'exclamation', id: 2004 },
{ name: 'question', id: 2005 },
],
position: { top: 3, left: 0 }
}
},
mounted() {
this.$nextTick(this.adjustPostion())
setTimeout(this.adjustPostion(), 10) //i hate racey bois
window.addEventListener('resize', this.adjustPostion())
},
beforeMount () {
this.adjustPostion()
},
methods: {
adjustPostion () {
this.position.left = null
this.position.right = -18
if (this.targetFromMe) {
this.position.right = null
this.position.left = -18
}
}
},
}
</script>
<style lang="scss" scoped>
.reactions {
position: relative;
// margin-top: 18px;
height: 18px;
width: 100%;
&.left {
.bubble {
&::before, &::after {
right: 0px;
left: auto;
}
&::after {
right: -4px;
}
}
}
.bubble {
background-color: #3A3A3C;
padding: 6px;
position: absolute;
border: solid 1px #1D1D1D;
border-radius: 50%;
width: 14px;
height: 14px;
line-height: 12px;
// transition: 0.3s;
&::before {
content: " ";
width: 8px;
height: 8px;
background-color: #3A3A3C;
border-radius: 50%;
position: absolute;
bottom: -1px;
left: 0px;
}
&::after {
content: " ";
width: 4px;
height: 4px;
background-color: #3A3A3C;
border-radius: 50%;
position: absolute;
bottom: -4px;
left: -4px;
}
svg {
width: 100%;
height: 100%;
}
.fa-layers {
height: 1.1em;
width: 1.1em;
}
&.isMe {
background-color: #1287FF;
&::before, &::after {
background-color: #1287FF;
}
}
}
}
.slide-fade-enter-active {
transition: all .2s ease;
}
.slide-fade-leave-active {
transition: all .2s ease;
}
.slide-fade-enter, .slide-fade-leave-to {
transform: scaleY(0);
margin-top: 0px;
height: 0px;
opacity: 0;
}
</style>

View File

@ -1,100 +1,174 @@
<template>
<transition name="fade">
<div class="modal" v-if="show">
<popover name="tunnel" event="hover" transition="fade">{{ relayMessage }}</popover>
<div class="modal__backdrop" @click="closeModal()" :class="{ nostyle: !($store.state.macstyle || process.platform === 'darwin') }"/>
<div
class="modal__backdrop"
@click="closeModal()"
:class="{
nostyle: !($store.state.macstyle || process.platform === 'darwin'),
}"
/>
<div class="modal__dialog">
<h3>Settings</h3>
<div class="selectors">
<div class="selector" :class="{ active: activeView == 'tweak'}" @click="activeView = 'tweak'">Tweak</div>
<div class="selector" :class="{ active: activeView == 'conversations'}" @click="activeView = 'conversations'">Conversations</div>
<div class="selector" :class="{ active: activeView == 'client'}" @click="activeView = 'client'">Client</div>
<h3>
Settings
</h3>
<div class="selectors">
<div
class="selector"
:class="{
active: activeView == 'tweak',
}"
@click="activeView = 'tweak'"
>
Tweak
</div>
<div
class="selector"
:class="{
active: activeView == 'conversations',
}"
@click="activeView = 'conversations'"
>
Conversations
</div>
<div
class="selector"
:class="{
active: activeView == 'client',
}"
@click="activeView = 'client'"
>
Client
</div>
<div class="settingsWrapper">
<div class="settingsColumn" v-if="activeView == 'tweak'">
<input type="password" placeholder="Password" class="textinput" v-model="password" />
<input type="text" placeholder="IP Address" class="textinput" v-model="ipAddress" :disabled="this.enableTunnel"/>
<div class="tunnelToggle">
<feather type="circle" size="20" @click="toggleTunnel" :fill="relayColor" v-popover:tunnel.bottom></feather>
</div>
<input ref="portField" type="number" placeholder="Port" class="textinput" min="1" max="65535" @keyup="enforceConstraints" v-model="port" />
<label class="switch">
<input type="checkbox" v-model="ssl">
<i></i>
<div>Enable SSL</div>
</label>
</div>
<div class="settingsColumn" v-else-if="activeView == 'conversations'">
<label class="switch">
<input type="checkbox" v-model="subjectLine">
<i></i>
<div>Enable subject line</div>
</label>
<label class="switch">
<input type="checkbox" v-model="transcode">
<i></i>
<div>Convert Apple formats <span style="font-size: 12px;">(mov, heic, caf)</span></div>
</label>
</div>
<div class="settingsColumn" v-if="activeView == 'client'">
<label class="select">
<div>Emoji style:</div>
<select v-model="emojiSet">
<option>Apple</option>
<option>Google</option>
<option>Twitter</option>
<option>Facebook</option>
<option>Native</option>
</select>
</label>
<label class="switch">
<input type="checkbox" v-model="systemSound">
<i></i>
<div>Use system notification sound</div>
</label>
<label class="switch">
<input type="checkbox" v-model="cacheMessages">
<i></i>
<div>Precache messages <span style="color: rgba(255,0,0,0.8);font-size: 12px;">More battery drain</span></div>
</label>
<label class="switch">
<input type="checkbox" v-model="launchOnStartup">
<i></i>
<div>Launch on startup</div>
</label>
<label class="switch">
<input type="checkbox" v-model="minimize">
<i></i>
<div>Close to tray</div>
</label>
<label class="switch" v-if="process.platform !== 'darwin'">
<input type="checkbox" v-model="macstyle">
<i></i>
<div>Use macOS style</div>
</label>
<label class="switch">
<input type="checkbox" v-model="acceleration">
<i></i>
<div>Enable hardware acceleration</div>
</label>
<label class="file">
<div>Select custom notification file:</div>
<input type="file" name="soundFile" ref="soundFile" style="display: none;" @change="notifSoundChanged" accept="audio/*">
<div class="fileBtn" @click.prevent="$refs.soundFile.click">
Browse ({{ this.notifSound.includes('wm-audio') ? 'Default' : this.notifSound.split('/').pop().split('\\').pop() }})
</div>
<div class="fileBtn" @click.prevent="notifSound = 'wm-audio://receivedText.mp3'" style="margin-left: 8px;">Reset</div>
</label>
</div>
</div>
<div class="settingsWrapper">
<div class="settingsColumn" v-if="activeView == 'tweak'">
<input type="password" placeholder="Password" class="textinput" v-model="password" />
<div class="IPGroup">
<input type="text" placeholder="IP Address" class="textinput" v-model="ipAddress" :disabled="enableTunnel" />
<div class="tunnelToggle" v-tooltip:left.tooltip="relayMessage">
<feather type="circle" size="20" @click="toggleTunnel" :fill="relayColor"></feather>
</div>
</div>
<input
ref="portField"
type="number"
placeholder="Port"
class="textinput"
min="1"
max="65535"
@keyup="enforceConstraints"
v-model="port"
/>
<label class="switch">
<input type="checkbox" v-model="ssl" />
<i></i>
<div>
Enable SSL
</div>
</label>
</div>
<div class="settingsColumn" v-else-if="activeView == 'conversations'">
<label class="switch">
<input type="checkbox" v-model="subjectLine" />
<i></i>
<div>
Enable subject line
</div>
</label>
<label class="switch">
<input type="checkbox" v-model="transcode" />
<i></i>
<div>
Convert Apple formats
<span style="font-size: 12px;">(mov, heic, caf)</span>
</div>
</label>
</div>
<div class="settingsColumn" v-if="activeView == 'client'">
<label class="select">
<div>
Emoji style:
</div>
<select v-model="emojiSet">
<option>Apple</option>
<option>Google</option>
<option>Twitter</option>
<option>Facebook</option>
<option>Native</option>
</select>
</label>
<label class="switch">
<input type="checkbox" v-model="systemSound" />
<i></i>
<div>
Use system notification sound
</div>
</label>
<label class="switch">
<input type="checkbox" v-model="cacheMessages" />
<i></i>
<div>
Precache messages
<span style="color: rgba(255,0,0,0.8);font-size: 12px;">More battery drain</span>
</div>
</label>
<label class="switch">
<input type="checkbox" v-model="launchOnStartup" />
<i></i>
<div>
Launch on startup
</div>
</label>
<label class="switch">
<input type="checkbox" v-model="minimize" />
<i></i>
<div>
Close to tray
</div>
</label>
<label class="switch" v-if="process.platform !== 'darwin'">
<input type="checkbox" v-model="macstyle" />
<i></i>
<div>
Use macOS style
</div>
</label>
<label class="switch">
<input type="checkbox" v-model="acceleration" />
<i></i>
<div>
Enable hardware acceleration
</div>
</label>
<label class="file">
<div>
Select custom notification file:
</div>
<input type="file" name="soundFile" ref="soundFile" style="display: none;" @change="notifSoundChanged" accept="audio/*" />
<div class="fileBtn" @click.prevent="$refs.soundFile.click;">
Browse ({{
notifSound.includes('wm-audio')
? 'Default'
: notifSound
.split('/')
.pop()
.split('\\')
.pop()
}})
</div>
<div class="fileBtn" @click.prevent="notifSound = 'wm-audio://receivedText.mp3'" style="margin-left: 8px;">
Reset
</div>
</label>
</div>
</div>
<a class="btn" v-on:click="saveModal">Save</a>
<a v-on:click="closeModal" class="btn destructive">Cancel</a>
<div class="version">
v{{version}}
</div>
<div class="version">v{{ version }}</div>
</div>
</div>
</transition>
@ -102,9 +176,16 @@
<script>
import usbmux from 'usbmux'
import { ipcRenderer } from 'electron'
import Tooltip from '@/components/Tooltip.vue'
export default {
name: "Settings",
name: 'Settings',
emits: ['saved'],
components: {
// eslint-disable-next-line vue/no-unused-components
Tooltip,
},
data() {
return {
show: false,
@ -123,31 +204,35 @@ export default {
process: window.process,
relay: null,
relayStatus: -1,
relayMessage: "Tunneling is currently disabled. Click the circle and ensure your device is attached to enable it.",
relayMessage: 'Tunneling is currently disabled. Click the circle and ensure your device is attached to enable it.',
relayColor: 'rgba(152,152,152,0.5)',
enableTunnel: false,
cacheMessages: false,
notifSound: 'wm-audio://receivedText.mp3',
emojiSet: 'Twitter',
activeView: 'tweak'
activeView: 'tweak',
}
},
beforeDestroy () {
beforeUnmount() {
if (this.relay) {
console.log('Destroying old tunnel...')
this.relay.stop()
try {
this.relay.stop()
} catch (err) {
console.warn('Error while destroying tunnel:', err)
}
}
},
methods: {
notifSoundChanged(e) {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const path = 'local-file://'+file.path
const path = 'local-file://' + file.path
this.notifSound = path
}
},
toggleTunnel () {
toggleTunnel() {
this.enableTunnel = !this.enableTunnel
this.$store.commit('setTunnel', this.enableTunnel)
if (this.enableTunnel) {
@ -155,36 +240,44 @@ export default {
} else {
if (this.relay) {
console.log('Destroying old tunnel...')
this.relay.stop()
try {
this.relay.stop()
} catch (err) {
console.warn('Error while destroying tunnel:', err)
}
this.$store.commit('setIPAddress', this.ipAddress)
this.$emit('saved')
}
this.relayMessage = "Tunneling is currently disabled. Click the circle and ensure your device is attached to enable it."
this.relayMessage = 'Tunneling is currently disabled. Click the circle and ensure your device is attached to enable it.'
this.relayColor = 'rgba(152,152,152,0.5)'
}
},
initTunnel () {
initTunnel() {
if (this.relay) {
console.log('Destroying old tunnel...')
this.relay.stop()
try {
this.relay.stop()
} catch (err) {
console.warn('Error while destroying tunnel:', err)
}
}
console.log('Initiating tunnel...')
this.relay = new usbmux.Relay(this.port, this.port)
.on('error', (err) => {
.on('error', err => {
if (err.message.includes('No devices connected')) {
this.relayMessage = "Error: No device is attached. Ensure that your device is plugged in and that you have iTunes installed."
this.relayMessage = 'Error: No device is attached. Ensure that your device is plugged in and that you have iTunes installed.'
this.relayStatus = 0
this.$store.commit('setIPAddress', this.ipAddress)
this.$emit('saved')
this.relayColor = 'rgba(255,0,0,0.5)'
}
})
.on('warning', (err) => {
.on('warning', err => {
if (err.message.includes('No devices connected')) {
this.relayMessage = "Error: No device is attached. Ensure that your device is plugged in and that you have iTunes installed."
this.relayMessage = 'Error: No device is attached. Ensure that your device is plugged in and that you have iTunes installed.'
this.relayStatus = 0
this.$store.commit('setIPAddress', this.ipAddress)
this.$emit('saved')
@ -192,16 +285,18 @@ export default {
}
})
.on('attached', () => {
this.relayMessage = "Tunneling is active and your device is attached. We will automatically setup the settings for you."
this.relayMessage = 'Tunneling is active and your device is attached. We will automatically setup the settings for you.'
this.relayStatus = 1
this.$store.commit('setIPAddress', '127.0.0.1')
if (this.$socket && this.$socket.readyState == 1) window.location.reload()
setTimeout(() => { this.$emit('saved') }, 10)
setTimeout(() => {
this.$emit('saved')
}, 10)
this.relayColor = 'rgba(0,255,0,0.5)'
})
},
saveModal() {
let reloadApp = this.$store.state.macstyle != this.macstyle || this.$store.state.acceleration != this.acceleration
const reloadApp = this.$store.state.macstyle != this.macstyle || this.$store.state.acceleration != this.acceleration
this.$store.commit('setPassword', this.password)
this.$store.commit('setIPAddress', this.ipAddress)
@ -224,7 +319,6 @@ export default {
} else {
this.$emit('saved')
}
if (reloadApp) ipcRenderer.send('reload_app')
},
@ -256,8 +350,8 @@ export default {
}
},
enforceConstraints() {
var el = this.$refs.portField
if (el.value != "") {
const el = this.$refs.portField
if (el.value != '') {
if (parseInt(el.value) < parseInt(el.min)) {
this.port = el.min
}
@ -265,9 +359,9 @@ export default {
this.port = el.max
}
}
}
},
},
mounted () {
mounted() {
this.loadValues()
ipcRenderer.send('app_version')
@ -275,11 +369,10 @@ export default {
ipcRenderer.removeAllListeners('app_version')
this.version = arg.version
})
}
},
}
</script>
<style lang="scss" scoped>
.version {
font-size: 12px;
@ -287,32 +380,42 @@ export default {
color: gray;
}
.tunnelToggle {
margin-top: -38px;
padding-bottom: 14px;
width: 20px;
position: relative;
left: 266px;
cursor: pointer;
.settingsColumn {
.IPGroup {
width: 100%;
position: relative;
.desc {
input {
width: calc(100% - 24px);
}
}
.tunnelToggle {
width: 20px;
position: absolute;
top: -27px;
right: 30px;
width: 180px;
height: 55px;
text-align: left;
background-color: rgba(25,25,25,0.9);
padding: 10px;
border-radius: 5px;
font-size: 13px;
display: inherit !important; /* override v-show display: none */
transition: opacity 0.3s;
left: 266px;
top: 8px;
cursor: pointer;
&[style*="display: none;"] {
opacity: 0;
pointer-events: none; /* disable user interaction */
user-select: none; /* disable user selection */
.desc {
position: absolute;
top: -27px;
right: 30px;
width: 180px;
height: 55px;
text-align: left;
background-color: rgba(25, 25, 25, 0.9);
padding: 10px;
border-radius: 5px;
font-size: 13px;
display: inherit !important; /* override v-show display: none */
transition: opacity 0.3s;
&[style*='display: none;'] {
opacity: 0;
pointer-events: none; /* disable user interaction */
user-select: none; /* disable user selection */
}
}
}
}
@ -336,14 +439,14 @@ export default {
z-index: 1;
border-radius: 10px;
&.nostyle {
&.nostyle {
border-radius: 0px;
}
}
&__dialog {
background-color: darken(#373737, 5%);
border: 1px solid darken(#282C2D, 10%);
border: 1px solid darken(#282c2d, 10%);
padding: 1.5rem;
position: relative;
top: 50%;
@ -357,17 +460,17 @@ export default {
.selectors {
font-weight: 400;
color: rgb(200,200,200);
color: rgb(200, 200, 200);
.selector {
display: inline-block;
margin-right: 10px;
margin-bottom: 20px;
cursor: pointer;
&:last-of-type {
margin-right: 0px;
}
&:last-of-type {
margin-right: 0px;
}
&:hover {
color: white;
@ -443,23 +546,23 @@ export default {
line-height: 1;
border: none;
font-weight: normal;
background-color: #42474A;
color: #B5AFA6;
background-color: #42474a;
color: #b5afa6;
padding: 0.65rem;
margin: 0.25rem;
font-size: 1rem;
box-shadow: none;
&:hover {
background-color: darken(#42474A, 5%);
background-color: darken(#42474a, 5%);
}
&:active {
background-color: darken(#42474A, 10%);
background-color: darken(#42474a, 10%);
}
&.destructive {
color: #DF4655;
color: #df4655;
}
&.cancel {
@ -471,7 +574,7 @@ export default {
width: 90%;
}
input:not([type="range"]):not([type="file"]):not([type="color"]):not(.message-input) {
input:not([type='range']):not([type='file']):not([type='color']):not(.message-input) {
height: auto;
height: inherit;
font-size: 13px;
@ -484,10 +587,10 @@ export default {
-webkit-app-region: no-drag;
}
input:not([type="range"]):not([type="file"]):not([type="color"]):not(.message-input):focus {
input:not([type='range']):not([type='file']):not([type='color']):not(.message-input):focus {
border-radius: 1px;
box-shadow: 0px 0px 0px 3.5px rgba(23, 101, 144, 1);
animation: showFocus .3s;
animation: showFocus 0.3s;
border-color: rgb(122, 167, 221) !important;
}
}
@ -531,7 +634,7 @@ label.file {
width: fit-content;
cursor: pointer;
display: inline-block;
border: 2px solid rgba(0,0,0,0.6);
border: 2px solid rgba(0, 0, 0, 0.6);
border-top: 0px;
border-left: 0px;
max-width: 215px;
@ -563,13 +666,13 @@ label.select {
border: 1px solid rgb(213, 213, 213);
-webkit-app-region: no-drag;
background-color: rgba(200, 200, 200, 0.1);
color: #EBECEC;
color: #ebecec;
border-radius: 0.5em;
&:focus {
border-radius: 0.25px;
box-shadow: 0px 0px 0px 3.5px rgba(23, 101, 144, 1);
animation: showFocus .3s;
animation: showFocus 0.3s;
border-color: rgb(122, 167, 221) !important;
}
@ -596,7 +699,7 @@ label.select {
i {
position: relative;
display: inline-block;
margin-right: .6rem;
margin-right: 0.6rem;
margin-top: -2px;
width: 46px;
height: 26px;
@ -606,7 +709,7 @@ label.select {
transition: all 0.3s linear;
&::before {
content: "";
content: '';
position: absolute;
left: 0;
width: 42px;
@ -618,7 +721,7 @@ label.select {
}
&::after {
content: "";
content: '';
position: absolute;
left: 0;
width: 22px;
@ -645,7 +748,7 @@ label.select {
}
input:checked + i {
background-color: #4BD763;
background-color: #4bd763;
}
input:checked + i::before {

176
src/components/Tooltip.vue Normal file
View File

@ -0,0 +1,176 @@
<template>
<span :tooltip="tooltipText" :position="position"><slot /></span>
</template>
<script>
export default {
name: 'Tooltip',
props: {
tooltipText: {
type: String,
default: 'Tooltip text',
},
position: {
default: 'top',
type: String,
},
},
}
</script>
<style lang="scss">
$gray: #555555;
[tooltip] {
& > * {
display: inline-block;
}
position: relative;
&:before,
&:after {
text-transform: none; /* opinion 2 */
font-size: 0.9em; /* opinion 3 */
line-height: 1;
user-select: none;
pointer-events: none;
position: absolute;
display: none;
opacity: 0;
}
&:before {
content: '';
border: 5px solid transparent; /* opinion 4 */
z-index: 1001; /* absurdity 1 */
}
&:after {
content: attr(tooltip); /* magic! */
/* most of the rest of this is opinion */
font-size: 14px;
/*
Let the content set the size of the tooltips
but this will also keep them from being obnoxious
*/
min-width: 3em;
max-width: 21em;
width: max-content;
// white-space: pre-line;
// overflow: hidden;
// word-wrap: anywhere;
// overflow-wrap: anywhere;
// word-break: break-word;
// text-overflow: ellipsis;
padding: 0.5rem;
border-radius: 0.3rem;
box-shadow: 0 1em 2em -0.5em rgba(0, 0, 0, 0.35);
background: $gray;
color: #fff;
z-index: 1000; /* absurdity 2 */
}
&:hover:before,
&:hover:after {
display: block;
}
/* position: TOP */
&:not([position]):before,
&[position^='top']:before {
bottom: 100%;
border-bottom-width: 0;
border-top-color: $gray;
}
&:not([position]):after,
&[position^='top']::after {
bottom: calc(100% + 5px);
}
&:not([position])::before,
&:not([position])::after,
&[position^='top']::before,
&[position^='top']::after {
left: 50%;
transform: translate(-50%, -0.5em);
}
/* position: BOTTOM */
&[position^='bottom']::before {
top: 105%;
border-top-width: 0;
border-bottom-color: $gray;
}
&[position^='bottom']::after {
top: calc(105% + 5px);
}
&[position^='bottom']::before,
&[position^='bottom']::after {
left: 50%;
transform: translate(-50%, 0.5em);
}
/* position: LEFT */
&[position^='left']::before {
top: 50%;
border-right-width: 0;
border-left-color: $gray;
left: calc(0em - 5px);
transform: translate(-0.5em, -50%);
}
&[position^='left']::after {
top: 50%;
right: calc(100% + 5px);
transform: translate(-0.5em, -50%);
}
/* position: RIGHT */
&[position^='right']::before {
top: 50%;
border-left-width: 0;
border-right-color: $gray;
right: calc(0em - 5px);
transform: translate(0.5em, -50%);
}
&[position^='right']::after {
top: 50%;
left: calc(100% + 5px);
transform: translate(0.5em, -50%);
}
/* FX All The Things */
&:not([position]):hover::before,
&:not([position]):hover::after,
&[position^='top']:hover::before,
&[position^='top']:hover::after,
&[position^='bottom']:hover::before,
&[position^='bottom']:hover::after {
animation: tooltips-vert 300ms ease-out forwards;
}
&[position^='left']:hover::before,
&[position^='left']:hover::after,
&[position^='right']:hover::before,
&[position^='right']:hover::after {
animation: tooltips-horz 300ms ease-out forwards;
}
}
/* don't show empty tooltips */
[tooltip='']::before,
[tooltip='']::after {
display: none !important;
}
/* KEYFRAMES */
@keyframes tooltips-vert {
to {
opacity: 0.9;
transform: translate(-50%, 0);
}
}
@keyframes tooltips-horz {
to {
opacity: 0.9;
transform: translate(0, -50%);
}
}
</style>

View File

@ -7,21 +7,17 @@
</template>
<script>
export default {
name: "TypingIndicator",
props: {
},
mounted() {
},
methods: {
},
name: 'TypingIndicator',
props: {},
mounted() {},
methods: {},
}
</script>
<style lang="scss" scoped>
<style lang="scss">
.typing-indicator {
$ti-color-bg: #3A3A3C;
$ti-color-bg: #3a3a3c;
background-color: $ti-color-bg;
will-change: transform;
width: auto;
@ -31,7 +27,7 @@ export default {
margin: 0 auto;
margin-top: 1px;
position: relative;
animation: 2s bulge infinite ease-out;
animation: 2s bulge infinite 0.3333s;
margin-bottom: 7px;
margin-left: 0;
@ -45,25 +41,27 @@ export default {
width: 10px;
border-radius: 50%;
background-color: $ti-color-bg;
animation: 2s bulge infinite 0.6666s;
}
&::after {
height: 5px;
width: 5px;
left: -4.5px;
bottom: -6px;
animation: 2s bulge infinite 1s;
}
span {
height: 7px;
width: 7px;
float: left;
margin: 0 1px;
background-color: #9E9EA1;
background-color: #9e9ea1;
display: block;
border-radius: 50%;
opacity: 0.4;
@for $i from 1 through 3 {
&:nth-of-type(#{$i}) {
animation: 1s blink infinite ($i * .3333s);
animation: 1s blink infinite ($i * 0.3333s);
}
}
}
@ -80,4 +78,4 @@ export default {
transform: scale(1.05);
}
}
</style>
</style>

View File

@ -1,6 +1,8 @@
<template>
<div style="white-space: pre-wrap;text-align:center;padding: 6px 10px;margin-top:2px;">
<div style="font-weight:500;font-size:14px;">Unsupported Type</div>
<div style="font-weight:500;font-size:14px;">
Unsupported Type
</div>
<div style="font-size:11px;margin-top:4px;">
This message cannot be viewed by WebMessage.
</div>
@ -9,13 +11,10 @@
</template>
<script>
export default {
name: "UnsupportedMessage",
methods: {
},
name: 'UnsupportedMessage',
methods: {},
}
</script>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>

View File

@ -4,23 +4,34 @@
<div class="progressContainer" v-if="isReading">
<div class="progress" :style="`width:${progress}%;`"></div>
</div>
<input ref="fileInput" type="file" style="visibility:hidden;width:0;height:0;" :accept="acceptedTypes" multiple @change="filesChanged" />
<input
ref="fileInput"
type="file"
style="visibility:hidden;width:0;height:0;"
:accept="acceptedTypes"
multiple
@change="filesChanged"
/>
</div>
</template>
<script>
export default {
name: "UploadButton",
name: 'UploadButton',
props: {
enableiMessageAttachments: { type: Boolean, default: false },
enableiMessageAttachments: {
type: Boolean,
default: false,
},
},
emits: ['filesChanged'],
data() {
return {
attachments: [],
progress: 0,
isReading: false,
sizeLimit: 100 // In MB. Files larger than this cause the client to crash for some reason.
// Although it's probably good to keep it low so it doesn't take 5 years.
sizeLimit: 100, // In MB. Files larger than this cause the client to crash for some reason.
// Although it's probably good to keep it low so it doesn't take 5 years.
}
},
computed: {
@ -29,15 +40,15 @@ export default {
if (this.enableiMessageAttachments) types = types + ',application/*,text/plain'
return types
},
attachmentsTooBig () {
attachmentsTooBig() {
if (this.enableiMessageAttachments) {
return 'iMessage attachments size cannot be larger than '+this.sizeLimit+'MB.'
return 'iMessage attachments size cannot be larger than ' + this.sizeLimit + 'MB.'
} else {
return 'SMS attachments size cannot be larger than '+this.sizeLimit*1000+'kB.'
return 'SMS attachments size cannot be larger than ' + this.sizeLimit * 1000 + 'kB.'
}
}
},
},
mounted () {
mounted() {
if (!this.enableiMessageAttachments) {
// SMS limitations recommend a max payload size of 300kB.
// This can vary between carriers, but there is no way to
@ -62,31 +73,31 @@ export default {
this.$emit('filesChanged')
},
async filesChanged() {
let attachments = this.attachments || []
const attachments = this.attachments || []
let totalSize = 0
let targetFiles = this.$refs.fileInput.files
const targetFiles = this.$refs.fileInput.files
this.progress = 0
this.isReading = true
for (let i = 0; i < targetFiles.length; i++) {
let attachment = {}
let file = targetFiles[i]
const attachment = {}
const file = targetFiles[i]
totalSize += file.size
attachment.name = file.name
attachment.data = await this.readFileData(file).catch(e => {
alert("An error occured while parsing file: '"+file.name+"'\n"+e)
alert("An error occured while parsing file: '" + file.name + "'\n" + e)
this.progress = null
this.attachments = null
return
})
attachments.push(attachment)
this.progress = Math.round((attachments.length/targetFiles.length)*100)
this.progress = Math.round((attachments.length / targetFiles.length) * 100)
}
if (totalSize > (this.sizeLimit * 1e6)) {
if (totalSize > this.sizeLimit * 1e6) {
this.attachments = null
this.isReading = false
alert(this.attachmentsTooBig)
@ -104,14 +115,14 @@ export default {
this.$emit('filesChanged')
this.$refs.fileInput.value = ''
},
async readFileData (file) {
async readFileData(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result.split('base64,').pop())
reader.onerror = error => reject(error)
})
}
},
},
}
</script>
@ -130,7 +141,7 @@ export default {
border-radius: 2px;
.progress {
background: #2284FF;
background: #2284ff;
width: 0%;
height: 100%;
-webkit-transition: width 1s ease-in-out;
@ -140,4 +151,4 @@ export default {
}
}
}
</style>
</style>

View File

@ -1,28 +1,60 @@
<template>
<video class="videoplayer" controls width="100%" @canplay="handleLoad" @canplaythrough="handleLoad" @loadeddata="handleLoad" @keypress.esc="escape" ref="videoPlayer">
<source :src="`${$store.getters.httpURI}/attachments?path=${encodeURIComponent(path)}&type=${encodeURIComponent(type)}&auth=${encodeURIComponent($store.state.password)}` + ($store.state.transcode ? '&transcode=1' : '')"
:type="type" />
<source :src="`${$store.getters.httpURI}/attachments?path=${encodeURIComponent(path)}&type=${encodeURIComponent(type)}&auth=${encodeURIComponent($store.state.password)}` + ($store.state.transcode ? '&transcode=1' : '')"
type="video/mp4" />
<source :src="`${$store.getters.httpURI}/attachments?path=${encodeURIComponent(path)}&type=${encodeURIComponent(type)}&auth=${encodeURIComponent($store.state.password)}` + ($store.state.transcode ? '&transcode=1' : '')"
type="video/ogg" />
This video type is not supported.
<video
class="videoplayer"
controls
width="100%"
@canplay="handleLoad"
@canplaythrough="handleLoad"
@loadeddata="handleLoad"
@keypress.esc="escape"
ref="videoPlayer"
>
<source
:src="
`${$store.getters.httpURI}/attachments?path=${encodeURIComponent(path)}&type=${encodeURIComponent(type)}&auth=${encodeURIComponent(
$store.state.password
)}` + ($store.state.transcode ? '&transcode=1' : '')
"
:type="type"
/>
<source
:src="
`${$store.getters.httpURI}/attachments?path=${encodeURIComponent(path)}&type=${encodeURIComponent(type)}&auth=${encodeURIComponent(
$store.state.password
)}` + ($store.state.transcode ? '&transcode=1' : '')
"
type="video/mp4"
/>
<source
:src="
`${$store.getters.httpURI}/attachments?path=${encodeURIComponent(path)}&type=${encodeURIComponent(type)}&auth=${encodeURIComponent(
$store.state.password
)}` + ($store.state.transcode ? '&transcode=1' : '')
"
type="video/ogg"
/>
This video type is not supported.
</video>
</template>
<script>
export default {
name: "VideoPlayer",
name: 'VideoPlayer',
props: {
path: { type: String },
type: { type: String },
loadedData: { type: Function }
path: {
type: String,
},
type: {
type: String,
},
loadedData: {
type: Function,
},
},
mounted() {
document.addEventListener('keydown', this.escape)
},
beforeDestroy () {
beforeUnmount() {
document.removeEventListener('keydown', this.escape)
},
methods: {
@ -33,7 +65,7 @@ export default {
if (e.key == 'Escape' && this.$refs.videoPlayer && document.fullscreenElement !== null) {
document.exitFullscreen()
}
}
},
},
}
</script>
@ -44,4 +76,4 @@ export default {
outline: none;
}
}
</style>
</style>

View File

@ -0,0 +1,22 @@
import { App, DirectiveBinding } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function init(el: Element, binding: DirectiveBinding<any>) {
const position = binding.arg || 'top'
const tooltipText = binding.value || 'Tooltip text'
el.setAttribute('position', position)
el.setAttribute('tooltip', tooltipText)
}
const tooltipDirective = (app: App<Element>) => {
app.directive('tooltip', {
mounted(el, binding) {
init(el, binding)
},
updated(el, binding) {
init(el, binding)
},
})
}
export default tooltipDirective

9
src/directives/index.ts Normal file
View File

@ -0,0 +1,9 @@
import tooltipDirective from './Tooltip/'
import { App } from 'vue'
// register all directives
const directives = (app: App<Element>) => {
tooltipDirective(app)
}
export default directives

View File

@ -1,86 +0,0 @@
import Vue from 'vue'
import store from '../store'
import emojiRegex from 'emoji-regex'
import { EmojiIndex, Emoji } from 'emoji-mart-vue-fast'
import emojiData from 'emoji-mart-vue-fast/data/all.json'
const emojiIndex = new EmojiIndex(emojiData)
const COLONS_REGEX = new RegExp(
'([^:]+)?(:[a-zA-Z0-9-_+]+:(:skin-tone-[2-6]:)?)',
'g')
const getEmojiComponent = emoji => {
let emojiSet = store.state.emojiSet.toLowerCase()
const EmojiCtor = Vue.extend(Emoji)
const vm = new EmojiCtor({
propsData: {
data: emojiIndex,
emoji: emoji,
set: emojiSet,
size: 16
}
})
vm.$mount()
let el = $(vm.$el).children().first()
let attributes = { draggable: false, native: emoji.native, alt: emoji.colons, src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' }
$.each(el[0].attributes, (id, attr) => {
attributes[attr.nodeName] = attr.nodeValue
})
attributes['background-image'] = null
el = $('<img />', attributes)
el.addClass('text-emoji')
el.css('width', '')
el.css('height', '')
if (emojiSet == 'native') {
el = $(document.createTextNode(emoji.native))
}
return el
}
Vue.filter('unescapeHTML', val => {
return val
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
})
Vue.filter('nativeEmoji', val => {
return val.replace(/\u{fffc}/gu, "").replace(emojiRegex(), (match, offset) => {
const before = val.substring(0, offset)
if (before.endsWith('alt="') || before.endsWith('native="')) {
return match
}
let emoji = emojiIndex.nativeEmoji(match)
if (!emoji) {
return match
}
let emojiComponent = getEmojiComponent(emoji).get(0)
return (emojiComponent.outerHTML ? emojiComponent.outerHTML : emojiComponent.textContent)
})
})
Vue.filter('colonEmoji', val => {
return val.replace(COLONS_REGEX, (match, p1, p2) => {
const before = p1 || ''
if (before.endsWith('alt="') || before.endsWith('native="')) {
return match
}
let emoji = emojiIndex.findEmoji(p2)
if (!emoji) {
return match
}
let emojiComponent = getEmojiComponent(emoji).get(0)
return before + (emojiComponent.outerHTML ? emojiComponent.outerHTML : emojiComponent.textContent)
})
})
Vue.filter('emojiComponent', emoji => {
return getEmojiComponent(emoji)
})

92
src/filters/index.ts Normal file
View File

@ -0,0 +1,92 @@
import store from '../store'
import emojiRegex from 'emoji-regex'
import { EmojiIndex, EmojiView } from 'emoji-mart-vue-fast/src/index'
import emojiData from 'emoji-mart-vue-fast/data/all.json'
const emojiIndex = new EmojiIndex(emojiData)
const COLONS_REGEX = new RegExp('([^:]+)?(:[a-zA-Z0-9-_+]+:(:skin-tone-[2-6]:)?)', 'g')
const getEmojiComponent = (emoji: EmojiObject) => {
const emojiSet = store.state.emojiSet.toLowerCase()
if (emojiSet == 'native') {
return $(document.createTextNode(emoji.native))
}
const emojiView = new EmojiView(emoji, null, emojiSet, null, null, null, 16)
const attributes = {
draggable: false,
native: emoji.native,
alt: emoji.colons,
src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
class: emojiView.cssClass.join(' '),
}
const $el = $('<img />', attributes)
Object.keys(emojiView.cssStyle).forEach((key: string) => {
$el.css(key, emojiView.cssStyle[key])
})
$el.addClass('text-emoji')
$el.css('width', '')
$el.css('height', '')
return $el
}
export default {
escapeHTML(val: string) {
const divEscapedHtml: HTMLElement = document.createElement('div')
const escapedHtmlTextNode: Text = divEscapedHtml.appendChild(document.createTextNode(val))
const escapedHtml: HTMLElement = escapedHtmlTextNode.parentNode as HTMLElement
const escapedHtmlContent: string = escapedHtml.innerHTML
escapedHtml.removeChild(escapedHtmlTextNode)
divEscapedHtml.parentNode?.removeChild(divEscapedHtml)
return escapedHtmlContent
},
unescapeHTML(val: string) {
return val
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
},
nativeEmoji(val: string) {
val = val.replace(/\u{fffc}/gu, '').replace(emojiRegex(), (match, offset) => {
const before = val.substring(0, offset)
if (before.endsWith('alt="') || before.endsWith('native="')) {
return match
}
const emoji = emojiIndex.nativeEmoji(match)
if (!emoji) {
return match
}
const emojiComponent = getEmojiComponent(emoji).get(0) as Element
return (emojiComponent.outerHTML ? emojiComponent.outerHTML : emojiComponent.textContent) as string
})
return val
},
colonEmoji(val: string) {
return val.replace(COLONS_REGEX, (match: string, p1: string, p2: string) => {
const before = p1 || ''
if (before.endsWith('alt="') || before.endsWith('native="')) {
return match
}
const emoji = emojiIndex.findEmoji(p2)
if (!emoji) {
return match
}
const emojiComponent = getEmojiComponent(emoji).get(0) as Element
return before + (emojiComponent.outerHTML ? emojiComponent.outerHTML : emojiComponent.textContent)
})
},
emojiComponent(emoji: EmojiObject) {
return getEmojiComponent(emoji)
},
}

View File

@ -1,88 +0,0 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import mixins from './mixin'
import axios from 'axios'
import VueNativeSock from 'vue-native-websocket'
import VueFeather from 'vue-feather'
// import Twemoji from './plugins/Twemoji'
import linkify from 'vue-linkify'
import $ from 'jquery'
import Popover from 'vue-js-popover'
import VueConfirmDialog from 'vue-confirm-dialog'
import { longClickDirective } from 'vue-long-click'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faHeart, faThumbsUp, faThumbsDown, faLaughSquint, faExclamation, faQuestion } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import { VLazyImagePlugin } from "v-lazy-image"
import rangy from 'rangy'
require('rangy/lib/rangy-selectionsaverestore')
window.$ = $
window.jQuery = $
require('imagesloaded')
import './filters'
const https = require('https')
// Vue.use(Twemoji, {
// extension: '.svg',
// size: 'svg'
// })
Vue.use(VueNativeSock, 'ws://', {
format: 'json',
reconnection: true,
connectManually: true,
})
Vue.use(VueFeather)
Vue.use(Popover, { tooltip: true })
Vue.use(VueConfirmDialog)
Vue.use(VLazyImagePlugin)
library.add(faHeart)
library.add(faThumbsUp)
library.add(faThumbsDown)
library.add(faLaughSquint)
library.add(faExclamation)
library.add(faQuestion)
Vue.component('font-awesome-icon', FontAwesomeIcon)
Vue.component('font-awesome-layers', FontAwesomeLayers)
Vue.component('vue-confirm-dialog', VueConfirmDialog.default)
Vue.config.productionTip = false
Vue.prototype.$http = axios.create({
httpsAgent: new https.Agent({
rejectUnauthorized: false
})
})
Object.defineProperty(Vue.prototype, '$rangy', { value: rangy })
Vue.mixin(mixins)
Vue.directive('linkified', linkify)
Vue.directive('longclick', longClickDirective({ delay: 800, interval: 0 }))
Vue.directive('click-outside', {
bind: (el, binding, vnode) => {
el.clickOutsideEvent = (event) => {
if (!(el == event.target || el.contains(event.target)) && !$('.emojiBtn').has($(event.target)).length) {
binding.value(event)
}
}
document.body.addEventListener('click', el.clickOutsideEvent)
},
unbind: (el) => {
document.body.removeEventListener('click', el.clickOutsideEvent)
},
})
axios.defaults.headers.common['Authorization'] = store.state.password
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

90
src/main.ts Normal file
View File

@ -0,0 +1,90 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import directives from './directives'
import filters from './filters'
import axios from 'axios'
import https from 'https'
import VueNativeSock from 'vue-native-websocket-vue3'
import VueFeather from 'vue-feather'
import linkify from 'vue-linkify'
import jQuery from 'jquery'
// import * as VueConfirmDialog from 'vue-confirm-dialog'
import { longClickDirective } from 'vue-long-click'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faHeart, faThumbsUp, faThumbsDown, faLaughSquint, faExclamation, faQuestion } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import VueLazyImage from '@techassi/vue-lazy-image'
declare global {
interface Window {
$: JQueryStatic
jQuery: JQueryStatic
}
}
window.$ = jQuery
window.jQuery = jQuery
// import 'imagesloaded'
const app = createApp(App)
.use(store)
.use(router)
app.use(VueNativeSock, 'ws://', {
format: 'json',
reconnection: true,
connectManually: true,
})
// app.use(Popover, { tooltip: true })
// app.use(VueConfirmDialog)
app.use(VueLazyImage)
library.add(faHeart)
library.add(faThumbsUp)
library.add(faThumbsDown)
library.add(faLaughSquint)
library.add(faExclamation)
library.add(faQuestion)
app.component('font-awesome-icon', FontAwesomeIcon)
app.component('font-awesome-layers', FontAwesomeLayers)
app.component('feather', VueFeather)
// app.component('vue-confirm-dialog', VueConfirmDialog.default)
// app.config.productionTip = false
axios.defaults.headers.common['Authorization'] = store.state.password
app.provide(
'$http',
axios.create({
httpsAgent: new https.Agent({
rejectUnauthorized: false,
}),
})
)
app.directive('linkified', linkify)
app.directive('longclick', longClickDirective({ delay: 800, interval: 0 }))
app.directive('click-outside', {
beforeMount: (el, binding) => {
el.clickOutsideEvent = (event: MouseEvent) => {
const target = event.target as Element
if (!(el == event.target || el.contains(event.target)) && !$('.emojiBtn').has(target).length) {
binding.value(event)
}
}
document.body.addEventListener('click', el.clickOutsideEvent)
},
unmounted: el => {
document.body.removeEventListener('click', el.clickOutsideEvent)
},
})
app.config.globalProperties.$filters = filters
directives(app)
app.mount('#app')
export default app

View File

@ -1,38 +0,0 @@
export default {
sockets: {
onmessage ({ data }) {
data.text().then((payload) => {
let json = JSON.parse(payload)
if (this.$options.socket && json) {
Object.keys(this.$options.socket).forEach(action => {
if (json.action == action) {
this.$options.socket[action].call(this, json.data)
}
})
}
})
},
onopen (e) {
if (this.$options.socket && this.$options.socket.onopen) {
this.$options.socket.onopen.call(this, e)
}
},
onclose (e) {
if (this.$options.socket && this.$options.socket.onclose) {
this.$options.socket.onclose.call(this, e)
}
},
onerror (e) {
if (this.$options.socket && this.$options.socket.onerror) {
this.$options.socket.onerror.call(this, e)
}
}
},
methods: {
sendSocket(obj) {
obj.password = this.$store.state.password
return this.$socket.send(JSON.stringify(obj))
}
}
}

12
src/mixin/sendSocket.ts Normal file
View File

@ -0,0 +1,12 @@
const mixin = {
methods: {
sendSocket(obj: { password: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const self = this as any
obj.password = self.$store.state.password
self.$socket.send(JSON.stringify(obj))
},
},
}
export default mixin

59
src/mixin/socket.ts Normal file
View File

@ -0,0 +1,59 @@
let lastCalledData: Nullable<Blob> = null
let lastCalledComponent: Nullable<string> = null
const mixin = {
sockets: {
onmessage({ data }: { data: Blob }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const self = this as any
if (lastCalledData != null && lastCalledData == data && lastCalledComponent != null && lastCalledComponent == self.$options.name)
return
lastCalledData = data
lastCalledComponent = self.$options.name
data.text().then(payload => {
const json = JSON.parse(payload)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (self.$options.socket && json) {
Object.keys(self.$options.socket).forEach(action => {
if (json.action == action) {
self.$options.socket[action].call(self, json.data)
}
})
}
})
},
onopen(e: Event) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const self = this as any
if (self.$options.socket && self.$options.socket.onopen) {
self.$options.socket.onopen.call(self, e)
}
},
onclose(e: Event) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const self = this as any
if (self.$options.socket && self.$options.socket.onclose) {
self.$options.socket.onclose.call(self, e)
}
},
onerror(e: Event) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const self = this as any
if (self.$options.socket && self.$options.socket.onerror) {
self.$options.socket.onerror.call(self, e)
}
},
},
methods: {
sendSocket(obj: { password: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const self = this as any
obj.password = self.$store.state.password
self.$socket.send(JSON.stringify(obj))
},
},
}
export default mixin

View File

@ -1,20 +0,0 @@
import twemoji from 'twemoji'
export default {
install (Vue, option) {
if (option) {
twemoji.base = option.baseUrl || twemoji.base
twemoji.ext = option.extension || twemoji.ext
twemoji.className = option.className || twemoji.className
twemoji.size = option.size || twemoji.size
}
Vue.$twemoji = twemoji
Object.defineProperties(Vue.prototype, {
$twemoji: {
get() {
return twemoji
}
}
})
}
}

View File

@ -1,7 +0,0 @@
const { remote, ipcRenderer, Notification, shell } = require('electron')
window.ipcRenderer = ipcRenderer
window.remote = remote
window.__dirname = __dirname
window.shell = shell
window.fs = require('fs')
window.path = require('path')

View File

@ -1,27 +0,0 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import Message from '../components/Message.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/message/:id',
name: 'Message',
component: Message
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router

23
src/router/index.ts Normal file
View File

@ -0,0 +1,23 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import Home from '../views/Home.vue'
import Chat from '../views/Chat/index.vue'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/chat/:id',
name: 'Chat',
component: Chat,
},
]
const router = createRouter({
history: createWebHashHistory(process.env.BASE_URL),
routes,
})
export default router

6
src/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/* eslint-disable */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -1,35 +1,32 @@
import Vue from 'vue'
import Vuex from 'vuex'
import { createStore } from 'vuex'
import Store from 'electron-store'
import axios from 'axios'
const Store = require('electron-store')
Vue.use(Vuex)
import { ipcRenderer } from 'electron'
const persistentStore = new Store()
export default new Vuex.Store({
export default createStore({
state: {
password: persistentStore.get('password', ''),
ipAddress: persistentStore.get('ipAddress', ''),
fallbackIpAddress: persistentStore.get('fallbackIpAddress', ''),
port: persistentStore.get('port', 8180),
ssl: persistentStore.get('ssl', true),
subjectLine: persistentStore.get('subjectLine', false),
transcode: persistentStore.get('transcode', true),
systemSound: persistentStore.get('systemSound', false),
launchOnStartup: persistentStore.get('launchOnStartup', false),
minimize: persistentStore.get('minimize', true),
macstyle: persistentStore.get('macstyle', true),
acceleration: persistentStore.get('acceleration', true),
messagesCache: [],
enableTunnel: persistentStore.get('enableTunnel', false),
cacheMessages: persistentStore.get('cacheMessages', false),
mutedChats: persistentStore.get('mutedChats', []),
notifSound: persistentStore.get('notifSound', 'wm-audio://receivedText.mp3'),
emojiSet: persistentStore.get('emojiSet', 'Twitter'),
isTyping: {},
isTypingTimer: {}
password: persistentStore.get('password', '') as string,
ipAddress: persistentStore.get('ipAddress', '') as string,
fallbackIpAddress: persistentStore.get('fallbackIpAddress', '') as string,
port: persistentStore.get('port', 8180) as number,
ssl: persistentStore.get('ssl', true) as boolean,
subjectLine: persistentStore.get('subjectLine', false) as boolean,
transcode: persistentStore.get('transcode', true) as boolean,
systemSound: persistentStore.get('systemSound', false) as boolean,
launchOnStartup: persistentStore.get('launchOnStartup', false) as boolean,
minimize: persistentStore.get('minimize', true) as boolean,
macstyle: persistentStore.get('macstyle', true) as boolean,
acceleration: persistentStore.get('acceleration', true) as boolean,
messagesCache: [] as object[],
enableTunnel: persistentStore.get('enableTunnel', false) as boolean,
cacheMessages: persistentStore.get('cacheMessages', false) as boolean,
mutedChats: persistentStore.get('mutedChats', []) as string[],
notifSound: persistentStore.get('notifSound', 'wm-audio://receivedText.mp3') as string,
emojiSet: persistentStore.get('emojiSet', 'Twitter') as string,
isTyping: [] as boolean[],
isTypingTimer: [] as NodeJS.Timeout[],
},
mutations: {
setPassword(state, password) {
@ -119,27 +116,27 @@ export default new Vuex.Store({
state['messagesCache'] = []
},
setTyping(state, data) {
Vue.set(state['isTyping'], data.chatId, data.isTyping)
state['isTyping'][data.chatId] = data.isTyping
if (!data.isTyping) return
if (state['isTypingTimer'][data.chatId]) clearTimeout(state['isTypingTimer'][data.chatId])
state['isTypingTimer'][data.chatId] = setTimeout(() => {
Vue.set(state['isTyping'], data.chatId, false)
state['isTyping'][data.chatId] = false
console.log('closing')
}, 60000)
}
},
},
getters: {
baseURI: state => {
var scheme = state['ssl'] ? "wss" : "ws"
return scheme + "://" + state['ipAddress'] + ":" + state['port'] + "?auth=" + encodeURIComponent(state['password'])
const scheme = state['ssl'] ? 'wss' : 'ws'
return scheme + '://' + state['ipAddress'] + ':' + state['port'] + '?auth=' + state['password']
},
httpURI: state => {
var scheme = state['ssl'] ? "https" : "http"
return scheme + "://" + state['ipAddress'] + ":" + state["port"]
}
const scheme = state['ssl'] ? 'https' : 'http'
return scheme + '://' + state['ipAddress'] + ':' + state['port']
},
},
actions: {
},
modules: {
}
actions: {},
modules: {},
})

27
src/types/modules.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
declare module 'emoji-mart-vue-fast'
declare module 'emoji-mart-vue-fast/src/index'
declare module 'vue/dist/vue.esm-bundler'
declare module 'emoji-mart-vue-fast/src/utils/emoji-data'
declare module 'vue-linkify'
declare module 'vue-confirm-dialog'
declare module 'vue-long-click'
declare module 'vue-js-popover'
declare module 'bplist-parser'
declare module '@techassi/vue-lazy-image'
declare module './mixin'
declare module '@trevoreyre/autocomplete-vue'
declare module '@/components/VideoPlayer'
declare module '@/components/ExpandableImage'
declare module '@/components/DownloadAttachment'
declare module '@/components/UploadButton'
declare module '@/components/ReactionMenu'
declare module '@/components/ReactionBubbles'
declare const __static: string
interface EmojiObject {
native: string
colons: string
}
type Nullable<T> = T | null

207
src/views/Chat/functions.ts Normal file
View File

@ -0,0 +1,207 @@
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 { state as messagesState, fetchMessages } from './messages'
let route = useRoute()
let currentInstance: Nullable<ComponentInternalInstance> = null
const state = reactive({
lastHeight: null as Nullable<number>,
ignoreNextScroll: false,
})
const scrollToBottom = () => {
if (currentInstance?.refs.messagesContainer) {
const container = currentInstance.refs.messagesContainer as HTMLElement
const scrollTo = state.lastHeight ? container.scrollHeight - state.lastHeight : container.scrollHeight
container.scrollTop = scrollTo
$(document).off('click', '.message a[href^="http"]')
$(document).on('click', '.message a[href^="http"]', function(event) {
event.preventDefault()
remote.shell.openExternal(this.href)
})
if (state.lastHeight == null) {
const messageInputRef = currentInstance?.refs.messageInput as HTMLElement
setTimeout(() => messageInputRef?.focus(), 10)
}
} else {
setTimeout(scrollToBottom, 1)
}
}
const postLoad = (initial: boolean) => {
nextTick(() => {
if (!currentInstance) return
const container = currentInstance.refs.messagesContainer as HTMLElement
if (container) {
if (initial) {
container.addEventListener('scroll', () => {
if (state.ignoreNextScroll && state.lastHeight == null) {
state.ignoreNextScroll = false
return
}
if (container.scrollHeight - (container.scrollTop + container.offsetHeight) <= 20) {
state.lastHeight = null
currentInstance?.emit('markAsRead', route.params.id)
} else state.lastHeight = container.scrollHeight - container.scrollTop
if (container.scrollTop == 0 && !messagesState.loading) {
fetchMessages()
}
})
}
state.ignoreNextScroll = true
scrollToBottom()
messagesState.offset += messagesState.limit
messagesState.loading = false
} else {
setTimeout(() => postLoad(initial), 1)
return
}
currentInstance.emit('markAsRead', route.params.id)
})
}
const isEmojis = (msgText: string) => {
const regex = emojiRegex()
msgText = msgText.replace(/\u{fffc}/gu, '')
return (
msgText.replace(' ', '').replace(regex, '').length == 0 &&
(msgText.match(regex) || []).length <= 8 &&
msgText.replace(' ', '').length != 0
)
}
const isImage = (type: string) => {
return type.includes('image/')
}
const isVideo = (type: string) => {
return type.includes('video/')
}
const isAudio = (file: string) => {
const ext = file.split('.').pop()
const formats = ['mp3', 'caf', 'wav', 'flac', 'm4a', 'wma', 'aac']
return ext ? formats.includes(ext.toLowerCase()) : false
}
const attachmentLoaded = () => {
state.ignoreNextScroll = true
scrollToBottom()
}
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') {
nextTick(() => {
const input = document.getElementsByClassName('autocomplete-input')[0] as HTMLElement
if (input) {
input.focus()
input.addEventListener('input', () => {
messagesState.receiver = ''
})
}
})
}
}
const autoCompleteInput = (input: { name: string; phone: string } | string) => {
const inputObj = input as { name: string; phone: 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 default () => {
route = useRoute()
currentInstance = getCurrentInstance()
const init = () => {
state.lastHeight = null
state.ignoreNextScroll = false
autoCompleteHooks()
}
onMounted(init)
watch(() => route.params.id, init)
return {
postLoad,
scrollToBottom,
isEmojis,
isImage,
isVideo,
isAudio,
attachmentLoaded,
rightClickMessage,
autoCompleteInput,
}
}

1010
src/views/Chat/index.vue Normal file

File diff suppressed because it is too large Load Diff

287
src/views/Chat/input.ts Normal file
View File

@ -0,0 +1,287 @@
import { ComponentInternalInstance, getCurrentInstance, nextTick, onMounted, reactive, watch } from 'vue'
import { useRoute } 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 axios from 'axios'
import { useStore } from 'vuex'
let currentInstance: Nullable<ComponentInternalInstance> = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const $rangy = rangy as any
interface TextObj {
[key: string]: string
}
const state = reactive({
messageText: {} as TextObj,
subjectText: {} as TextObj,
canSend: true,
hasAttachments: false,
lastTypingValue: '',
lastTypingStamp: 0,
hasDataToSend: false,
lastFocus: null as Nullable<{ target?: JQuery<HTMLElement>; selection?: Range; lastSavedPos?: object }>,
showEmojiMenu: false,
emojiIndex: new EmojiIndex(emojiData),
})
export default () => {
const watchRoute = useRoute()
const store = useStore()
currentInstance = getCurrentInstance()
const init = () => {
state.canSend = true
state.hasAttachments = false
state.lastTypingValue = ''
state.lastTypingStamp = 0
state.hasDataToSend = false
state.lastFocus = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (currentInstance?.refs.uploadButton) (currentInstance?.refs.uploadButton as any).clear()
if (currentInstance?.refs.messageInput) {
const input = currentInstance.refs.messageInput as HTMLElement
input.innerHTML = state.messageText[watchRoute.params.id as string] ?? ''
}
if (currentInstance?.refs.subjectInput) {
const input = currentInstance.refs.subjectInput as HTMLElement
input.innerHTML = state.subjectText[watchRoute.params.id as string] ?? ''
}
}
const sendTypingIndicator = (value: string) => {
if (state.lastTypingValue == value || !messagesState.messages[0]) return
if (Date.now() - state.lastTypingStamp <= 55000 && value != '' && state.lastTypingValue != '') return
state.lastTypingValue = value
state.lastTypingStamp = Date.now()
if (value != '') {
sendSocket({
action: 'setIsLocallyTyping',
data: {
chatId: messagesState.messages[0].chatId,
typing: true,
},
})
} else {
sendSocket({
action: 'setIsLocallyTyping',
data: {
chatId: messagesState.messages[0].chatId,
typing: false,
},
})
}
}
const sendText = () => {
if (!state.canSend) return
let messageText = state.messageText[watchRoute.params.id as string] || ''
let subjectText = state.subjectText[watchRoute.params.id as string] || ''
if (messageText == '' && subjectText != '') {
messageText = subjectText
subjectText = ''
}
if (
messageText == '' &&
(!currentInstance?.refs.uploadButton ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
!(currentInstance?.refs.uploadButton as any).attachments ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(currentInstance?.refs.uploadButton as any).attachments.length == 0)
)
return
state.canSend = false
const textObj = {
text: messageText,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attachments: currentInstance?.refs.uploadButton ? (currentInstance?.refs.uploadButton as any).attachments : [],
address: messagesState.messages[0] ? messagesState.messages[0].chatId : messagesState.receiver,
subject: subjectText,
}
axios
.post(store.getters.httpURI + '/sendText', textObj)
.then(() => {
const focusedEl = $(document.activeElement as HTMLElement).attr('id')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uploadButtonRef = currentInstance?.refs.uploadButton as any
if (uploadButtonRef) {
uploadButtonRef.clear()
}
nextTick(() => $('#' + focusedEl).focus())
delete state.messageText[watchRoute.params.id as string]
delete state.subjectText[watchRoute.params.id as string]
const messageInputRef = currentInstance?.refs.messageInput as HTMLElement
const subjectInputRef = currentInstance?.refs.subjectInput as HTMLElement
if (messageInputRef) {
messageInputRef.innerHTML = ''
}
if (subjectInputRef) {
subjectInputRef.innerHTML = ''
}
state.canSend = true
state.hasDataToSend = false
})
.catch(error => {
alert('There was an error while sending your text.\n' + error)
state.canSend = true
})
}
const previewFiles = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uploadButtonRef = currentInstance?.refs.uploadButton as any
state.hasAttachments = uploadButtonRef && uploadButtonRef.attachments != null && uploadButtonRef.attachments.length > 0
state.hasDataToSend =
(state.messageText[watchRoute.params.id as string] != '' && state.messageText[watchRoute.params.id as string] != null) ||
(state.subjectText[watchRoute.params.id as string] != '' && state.subjectText[watchRoute.params.id as string] != null) ||
state.hasAttachments
}
const removeAttachment = (i: number) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uploadButtonRef = currentInstance?.refs.uploadButton as any
if (!state.canSend) return
uploadButtonRef?.remove(i)
}
const enterKey = (e: Event) => {
e.preventDefault()
e.stopPropagation()
sendText()
}
const shiftEnterKey = (e: Event) => {
e.preventDefault()
e.stopPropagation()
if (
(e.target as HTMLElement).innerHTML === '' ||
(e.target as HTMLElement).innerHTML[(e.target as HTMLElement).innerHTML.length - 1] !== '\n'
) {
document.execCommand('insertHTML', false, '\n')
}
document.execCommand('insertHTML', false, '\n')
}
const messageInputPasted = (e: ClipboardEvent) => {
e.preventDefault()
e.stopPropagation()
let paste = e.clipboardData?.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))
if (content != target.innerHTML) {
const savedPos = $rangy.saveSelection()
content = filters.nativeEmoji(filters.colonEmoji(target.innerHTML))
target.innerHTML = content ?? ''
$rangy.restoreSelection(savedPos)
}
const rawContent = filters.unescapeHTML((e.target as HTMLElement).innerHTML.replace(/<img.*?native="(.*?)"[^>]+>/g, '$1'))
if ($(e.target as HTMLElement).hasClass('messageInput')) {
state.messageText[watchRoute.params.id as string] = rawContent
} else if ($(e.target as HTMLElement).hasClass('subjectInput')) {
state.subjectText[watchRoute.params.id as string] = rawContent
}
state.hasDataToSend =
(state.messageText[watchRoute.params.id as string] != '' && state.messageText[watchRoute.params.id as string] != null) ||
(state.subjectText[watchRoute.params.id as string] != '' && state.subjectText[watchRoute.params.id as string] != null) ||
state.hasAttachments
sendTypingIndicator(rawContent)
}
const handleBlur = (e: FocusEvent) => {
if (e.relatedTarget && (e.relatedTarget as HTMLInputElement).placeholder == 'Search') return
state.lastFocus = {
target: $(e.target as HTMLElement),
selection: window.getSelection()?.getRangeAt(0),
}
}
const insertEmoji = (emoji: EmojiObject) => {
if (!state.lastFocus) state.lastFocus = { target: $(currentInstance?.refs.messageInput as HTMLElement) }
if (!state.lastFocus.target) state.lastFocus.target = $(currentInstance?.refs.messageInput as HTMLElement)
const emojiComponent = filters.emojiComponent(emoji)
if (state.lastFocus.selection) {
state.lastFocus.target.focus()
const range = state.lastFocus.selection
const node = emojiComponent.get(0)
const windowRange = window.getSelection()?.getRangeAt(0)
// this.lastFocus.lastSavedPos = this.$rangy.saveSelection()
range.insertNode(node)
state.lastFocus.selection.setEndAfter(node)
state.lastFocus.selection.setStartAfter(node)
windowRange?.setEndAfter(node)
windowRange?.setStartAfter(node)
if (state.lastFocus.lastSavedPos) $rangy.restoreSelection(state.lastFocus.lastSavedPos)
} else {
state.lastFocus.target.append(emojiComponent)
}
messageInputChanged(({ target: state.lastFocus.target.get(0) } as unknown) as Event)
}
const toggleEmojiMenu = () => {
state.showEmojiMenu = !state.showEmojiMenu
if (state.lastFocus) {
state.lastFocus.target?.focus()
if (state.lastFocus.selection)
window.getSelection()?.collapse(state.lastFocus.selection.startContainer, state.lastFocus.selection.startOffset)
}
}
const hideEmojiMenu = () => {
if (!state.showEmojiMenu) return
state.showEmojiMenu = false
}
onMounted(init)
watch(() => watchRoute.params.id, init)
return {
enterKey,
shiftEnterKey,
previewFiles,
removeAttachment,
messageInputPasted,
messageInputChanged,
handleBlur,
insertEmoji,
toggleEmojiMenu,
hideEmojiMenu,
state,
}
}

428
src/views/Chat/messages.ts Normal file
View File

@ -0,0 +1,428 @@
import { reactive, computed, ComputedRef, watch, ComponentInternalInstance, getCurrentInstance, nextTick } from 'vue'
import main from '@/main'
import { postLoad, scrollToBottom, hookPasteAndDrop, state as functionsState } from './functions'
import { parseBuffer } from 'bplist-parser'
import moment from 'moment'
import { RouteLocationNormalizedLoaded, Router, useRoute, useRouter } from 'vue-router'
import { Store, useStore } from 'vuex'
interface ReloadGUIDObject {
[key: string]: Nullable<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({
messages: [] as { chatId: string; guid: string; reactions: object; date: number; dateRead: number; dateDelivered: number }[],
limit: 25,
offset: 0,
receiver: '',
loading: false,
reloadingGuid: {} as ReloadGUIDObject,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sortedMessages: ComputedRef<any> = computed(() => {
const messages = state.messages.slice(0).sort((a, b) => (b.date - a.date > 0 ? 1 : -1))
let lastSentMessageFound = false
let lastReadMessageFound = false
const groupDates = (date1: number, date2: number) => date2 - date1 < 3600000
const groupAuthor = (author1: string, author2: string) => author1 == author2
const groupedMessages = messages.reduce(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(r: any, { text, subject, dateRead, dateDelivered, guid, reactions, payload, attachments, balloonBundle, ...rest }: any, i, arr) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prev: any = arr[i + -1]
const extras: { payload?: object; undisplayable?: boolean } = {}
if (payload) {
try {
const data = Buffer.from(payload, 'base64')
const payloadBuffer = parseBuffer(data)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsedData: any = {}
let lastClassname = 'root'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadBuffer[0].$objects.forEach((object: any) => {
if (object.$classname) lastClassname = object.$classname
if (typeof object != 'string' || object == 'website' || object == '$null' || object.startsWith('image/')) return
if (lastClassname == 'NSUUID') throw Error('MSMessage payload')
if (!parsedData[lastClassname]) parsedData[lastClassname] = []
parsedData[lastClassname].push(object)
})
extras.payload = parsedData
} catch (error) {
extras.undisplayable = true
}
if (Date.now() - rest.date >= 15000 && Object.keys(extras.payload ?? {}).length <= 1) {
delete extras.payload
payload = null
balloonBundle = null
}
const supportedFormats = ['', 'com.apple.messages.URLBalloonProvider']
if (!extras.undisplayable && balloonBundle && !supportedFormats.includes(balloonBundle)) {
extras.undisplayable = true
attachments = []
}
}
if (prev && groupAuthor(rest.author, prev.author) && groupAuthor(rest.sender, prev.sender) && groupDates(rest.date, prev.date)) {
r[r.length - 1].texts.unshift({
text: text,
subject: subject,
date: rest.date,
attachments: attachments,
read: dateRead,
delivered: dateDelivered,
guid: guid,
reactions: reactions,
showStamp: rest.sender == 1 && (!lastSentMessageFound || (!lastReadMessageFound && dateRead > 0)),
balloon: balloonBundle != null,
...extras,
})
} else {
r.push({
...rest,
texts: [
{
text: text,
subject: subject,
date: rest.date,
attachments: attachments,
read: dateRead,
delivered: dateDelivered,
guid: guid,
reactions: reactions,
showStamp: rest.sender == 1 && (!lastSentMessageFound || (!lastReadMessageFound && dateRead > 0)),
balloon: balloonBundle != null,
...extras,
},
],
})
}
if (rest.sender == 1 && !lastSentMessageFound) lastSentMessageFound = true
if (rest.sender == 1 && !lastReadMessageFound && dateRead > 0) lastReadMessageFound = true
return r
},
[]
)
return groupedMessages.reverse()
})
// 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))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fetchMessagesHandler = (response: any) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: Array<any> = response.data
if (data && route && data[0] && data[0].personId != route.params.id) return
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
const newMessageHandler = (response: any) => {
if (!route || !router || !store) return
const message = response.data.message
if (route.params.id == 'new') {
if (Object.keys(message).length > 0) {
if (message[0].chatId == state.receiver) {
router.push('/message/' + message[0].personId)
}
}
return
}
if (Object.keys(message).length == 0) {
console.log('Received a message, but content was empty.')
} else {
if (state.messages && state.messages.length > 0 && message[0]['personId'] == route.params.id) {
state.reloadingGuid[message[0].guid] = null
const oldMsgIndex = state.messages.findIndex(obj => obj.guid == message[0].guid)
if (oldMsgIndex != -1) {
state.messages[oldMsgIndex] = message[0]
return
}
if (message[0].sender != 1) {
store.commit('setTyping', {
chatId: route.params.id,
isTyping: false,
})
}
state.messages.unshift(message[0])
if (functionsState.lastHeight == null) {
functionsState.ignoreNextScroll = true
nextTick(scrollToBottom)
}
if (functionsState.lastHeight == null) {
currentInstance?.emit('markAsRead', route.params.id)
}
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newReactionHandler = (response: any) => {
const reactions = response.data.reactions
if (reactions && reactions.length > 0) {
const msgIndex = state.messages.findIndex(obj => obj.guid == reactions[0].forGUID)
if (msgIndex > -1) {
state.messages[msgIndex].reactions = reactions
let intervalTime = 0
nextTick(() => {
if (functionsState.lastHeight == null) {
const interval = setInterval(() => {
if (functionsState.lastHeight == null) scrollToBottom()
if (intervalTime >= 200) clearInterval(interval)
intervalTime += 1
}, 1)
}
})
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const setAsReadHandler = (response: any) => {
const data = response.data
if (route?.params.id == 'new' || !data) return
if (route?.params.id == data.chatId) {
const messageIndex = state.messages.findIndex(obj => obj.guid == data.guid)
if (messageIndex > -1) {
state.messages[messageIndex]['dateRead'] = data.read
state.messages[messageIndex]['dateDelivered'] = data.delivered
if (functionsState.lastHeight == null) {
functionsState.ignoreNextScroll = true
nextTick(scrollToBottom)
}
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const setTypingIndicatorHandler = (response: any) => {
const data = response.data
if (data && data.chat_id == route?.params.id && functionsState.lastHeight == null) {
functionsState.ignoreNextScroll = true
nextTick(scrollToBottom)
}
}
const onSocketMessage = (event: MessageEvent) => {
event.data.text().then((responseText: string) => {
const response = JSON.parse(responseText)
if (store && route && response) {
switch (response.action) {
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
default:
return
}
}
})
}
const onSocketDisconnect = () => {
state.messages = []
state.limit = 25
state.offset = 0
state.receiver = ''
state.loading = false
}
const onSocketConnected = () => {
state.messages = []
state.limit = 25
state.offset = 0
state.receiver = ''
state.loading = false
// eslint-disable-next-line @typescript-eslint/no-use-before-define
fetchMessages()
}
// 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()
return
}
if (socket && socket.readyState == 1) {
if (state.loading || !route) return
state.loading = true
sendSocket({
action: 'fetchMessages',
data: {
id: route.params.id,
offset: `${state.offset}`,
limit: `${state.limit}`,
},
})
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(fetchMessages, 1000)
}
}
const reloadMessage = (guid: string) => {
if (state.reloadingGuid[guid]) return
state.reloadingGuid[guid] = true
sendSocket({
action: 'getMessageByGUID',
data: {
guid: guid,
},
})
}
const humanReadableDay = (date: number) => {
const ts = date
const today = new Date()
const tsDate = new Date(ts).setHours(0, 0, 0, 0)
if (new Date().setHours(0, 0, 0, 0) == tsDate) {
return 'Today'
} else if (tsDate >= new Date().setHours(0, 0, 0, 0) - 86400000) {
return 'Yesterday'
} else {
const lastWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7).setHours(0, 0, 0, 0)
if (ts >= lastWeek) {
return moment(ts).format('dddd')
}
}
const lastYear = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()).setHours(0, 0, 0, 0)
if (ts < lastYear) {
return moment(ts).format('ll') + ','
}
return moment(ts).format('ddd, MMM D') + ','
}
const dateGroup = (prev: number, current: number) => {
const prevstamp = sortedMessages.value[prev] ? sortedMessages.value[prev].texts[0].date : 0
const currstamp = sortedMessages.value[current].texts[0].date
if (prev == -1 || currstamp - prevstamp > 3600000) {
const humanReadableStamp = humanReadableDay(currstamp)
return `<span class="bold">${humanReadableStamp}</span> ${moment(currstamp).format('LT')}`
} else {
return ''
}
}
const humanReadableTimestamp = (date: number) => {
const ts = date
const tsDate = new Date(ts).setHours(0, 0, 0, 0)
if (new Date().setHours(0, 0, 0, 0) == tsDate) {
return moment(ts).format('LT')
} else if (tsDate >= new Date().setHours(0, 0, 0, 0) - 86400000) {
return 'Yesterday'
} else {
const today = new Date()
const lastWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7).setHours(0, 0, 0, 0)
if (ts >= lastWeek) {
return moment(ts).format('dddd')
}
}
return moment(ts).format('M/D/YY')
}
export { state, fetchMessages, sendSocket }
export default () => {
store = useStore()
route = useRoute()
router = useRouter()
currentInstance = getCurrentInstance()
const init = () => {
state.messages = []
state.limit = 25
state.offset = 0
state.receiver = ''
state.loading = false
state.reloadingGuid = {}
fetchMessages()
}
init()
watch(() => route?.params.id, init)
return {
state,
sortedMessages,
fetchMessages,
dateGroup,
humanReadableTimestamp,
reloadMessage,
}
}

113
src/views/Chat/reactions.ts Normal file
View File

@ -0,0 +1,113 @@
import { onMounted, reactive, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ipcRenderer, IpcRendererEvent } from 'electron'
import { sendSocket, state as messagesState } from './messages'
const state = reactive({
interval: null as Nullable<NodeJS.Timeout>,
timeHolding: 0,
initialX: null as Nullable<number>,
initialY: null as Nullable<number>,
reactingMessage: null as Nullable<JQuery<HTMLElement>>,
reactingMessageGUID: null as Nullable<string>,
reactingMessageReactions: null as Nullable<object>,
reactingMessagePart: 0,
reactingToBalloon: false,
})
const openReactionMenu = (msgId: string, textId: string, guid: string, reactions: object, part: number, balloon: boolean) => {
const el = $('#msg' + msgId + '-text' + textId + '-part' + part)
state.reactingMessageReactions = reactions
state.reactingMessageGUID = guid
state.reactingMessagePart = part
state.reactingMessage = el
state.reactingToBalloon = balloon
}
const startInterval = (msgId: string, textId: string, guid: string, reactions: object, part: number, balloon: boolean) => {
if (!state.interval) {
state.interval = setInterval(() => {
state.timeHolding++
if (state.timeHolding > 7) {
//> 0.7 seconds, will trigger at 0.8 seconds
openReactionMenu(msgId, textId, guid, reactions, part, balloon)
if (state.interval) clearInterval(state.interval)
state.interval = null
state.timeHolding = 0
}
}, 100)
}
}
const stopInterval = () => {
if (state.interval) clearInterval(state.interval)
state.interval = null
state.timeHolding = 0
state.initialX = null
state.initialY = null
}
const stopIntervalWhen = (e: MouseEvent) => {
if (state.interval) {
if (!state.initialX) state.initialX = e.clientX
if (!state.initialY) state.initialY = e.clientY
if (Math.abs(state.initialX - e.clientX) > 4 || Math.abs(state.initialY - e.clientY) > 4) {
stopInterval()
}
}
}
const closeReactionMenu = () => {
state.reactingMessage = null
state.reactingMessageGUID = null
state.reactingMessageReactions = null
state.reactingMessagePart = 0
}
const sendReaction = (reactionId: string, guid: string, part: string) => {
if (!messagesState.messages[0]) return
console.log(reactionId, guid, part)
sendSocket({
action: 'sendReaction',
data: {
chatId: messagesState.messages[0].chatId,
guid: guid,
reactionId: reactionId,
part: part,
},
})
}
export default () => {
const route = useRoute()
const init = () => {
state.reactingMessage = null
state.reactingMessageGUID = null
state.reactingMessageReactions = null
state.reactingMessagePart = 0
state.interval = null
state.timeHolding = 0
state.initialX = null
state.initialY = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ipcRenderer.on('reactToMessage', (_e: IpcRendererEvent, args: any) => {
openReactionMenu(args.id, args.ii, args.guid, args.reactions, args.part, args.balloon)
})
}
onMounted(init)
watch(() => route?.params.id, init)
return {
openReactionMenu,
startInterval,
stopInterval,
stopIntervalWhen,
closeReactionMenu,
sendReaction,
state,
}
}

View File

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

44
tsconfig.json Normal file
View File

@ -0,0 +1,44 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"noImplicitThis": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"resolveJsonModule": true,
"baseUrl": ".",
"types": [
"webpack-env",
"jquery"
],
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx",
"src/mixin/index.js"
],
"exclude": [
"node_modules"
]
}

View File

@ -1,41 +1,48 @@
module.exports = {
productionSourceMap: true,
configureWebpack: {
target: "electron-renderer",
target: 'electron-renderer',
node: {
__filename: true,
__dirname: true
}
__dirname: true,
},
resolve: {
alias: {
vue$: 'vue/dist/vue.esm-bundler',
},
},
},
pluginOptions: {
electronBuilder: {
appId: "com.sgtaziz.WebMessage",
productName: "WebMessage",
appId: 'com.sgtaziz.WebMessage',
productName: 'WebMessage',
outputDir: 'build',
preload: 'src/preload.js',
nodeIntegration: true,
// preload: 'src/preload.js',
builderOptions: {
appId: "com.sgtaziz.WebMessage",
productName: "WebMessage",
appId: 'com.sgtaziz.WebMessage',
productName: 'WebMessage',
publish: ['github'],
snap: {
publish: ['github']
publish: ['github'],
},
win: {
icon: 'build/icons/icon.ico'
icon: 'build/icons/icon.ico',
},
mac: {
icon: 'build/icons/icon.icns'
icon: 'build/icons/icon.icns',
},
extraFiles: [
{
from: 'node_modules/node-notifier/vendor/',
to: 'resources/vendor'
to: 'resources/vendor',
},
{
from: 'vendor/',
to: 'resources/terminal-notifier/vendor'
}
]
}
}
}
to: 'resources/terminal-notifier/vendor',
},
],
},
},
},
}

7365
yarn.lock

File diff suppressed because it is too large Load Diff