mirror of
https://github.com/Cronocide/WebMessage.git
synced 2025-01-22 11:18:25 +00:00
Initial rewrite (Vue3, Electron 11, TypeScript)
This commit is contained in:
parent
a1c7187c1d
commit
58fa723720
36
.eslintrc.js
Normal file
36
.eslintrc.js
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
@ -1,5 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
presets: ['@vue/cli-plugin-babel/preset'],
|
||||
}
|
||||
|
57
package.json
57
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
534
src/App.vue
534
src/App.vue
@ -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>–</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>–</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;
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
273
src/components/ConfirmDialog.vue
Normal file
273
src/components/ConfirmDialog.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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
@ -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>
|
||||
|
235
src/components/ReactionBubbles.vue
Normal file
235
src/components/ReactionBubbles.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
@ -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
176
src/components/Tooltip.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
22
src/directives/Tooltip/index.ts
Normal file
22
src/directives/Tooltip/index.ts
Normal 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
9
src/directives/index.ts
Normal 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
|
@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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
92
src/filters/index.ts
Normal 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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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)
|
||||
},
|
||||
}
|
88
src/main.js
88
src/main.js
@ -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
90
src/main.ts
Normal 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
|
@ -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
12
src/mixin/sendSocket.ts
Normal 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
59
src/mixin/socket.ts
Normal 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
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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')
|
@ -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
23
src/router/index.ts
Normal 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
6
src/shims-vue.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/* eslint-disable */
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
@ -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
27
src/types/modules.d.ts
vendored
Normal 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
207
src/views/Chat/functions.ts
Normal 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
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
287
src/views/Chat/input.ts
Normal 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
428
src/views/Chat/messages.ts
Normal 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
113
src/views/Chat/reactions.ts
Normal 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,
|
||||
}
|
||||
}
|
@ -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
44
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user