Archived
2
0

merge from remote

This commit is contained in:
gbrian
2021-03-29 11:03:25 +00:00
parent a1fcf87345
commit 8efc5d7094
95 changed files with 5789 additions and 874 deletions

View File

@ -15,53 +15,55 @@
"scripts": {
"serve": "vue-cli-service serve --mode development",
"build": "vue-cli-service build",
"build:lib": "vue-cli-service build --target lib --name neko-lib 'src/lib.ts'",
"build:emoji": "ts-node --files --project tools/tsconfig.json tools/emoji.ts",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.13.0",
"animejs": "^3.1.0",
"axios": "^0.19.1",
"date-fns": "^2.11.1",
"emoji-datasource": "^5.0.1",
"emojilib": "^2.4.0",
"eventemitter3": "^4.0.0",
"@fortawesome/fontawesome-free": "^5.14.0",
"animejs": "^3.2.0",
"axios": "^0.21.1",
"date-fns": "^2.16.1",
"emoji-datasource": "^6.0.1",
"emojilib": "^3.0.1",
"eventemitter3": "^4.0.7",
"resize-observer-polyfill": "^1.5.1",
"simple-markdown": "^0.7.2",
"sweetalert2": "^9.10.9",
"typed-vuex": "^0.1.17",
"sweetalert2": "^10.15.7",
"typed-vuex": "^0.1.21",
"v-tooltip": "^2.0.3",
"vue": "^2.6.10",
"vue-class-component": "^7.2.3",
"vue": "^2.6.12",
"vue-class-component": "^7.2.6",
"vue-clickaway": "^2.2.2",
"vue-context": "^5.1.0",
"vue-i18n": "^8.16.0",
"vue-context": "^5.2.0",
"vue-i18n": "^8.21.1",
"vue-notification": "^1.3.20",
"vue-property-decorator": "^8.4.1",
"vuex": "^3.1.3"
"vue-property-decorator": "^9.1.2",
"vuex": "^3.5.1"
},
"devDependencies": {
"@types/animejs": "^3.1.0",
"@types/node": "^13.7.0",
"@types/animejs": "^3.1.2",
"@types/node": "^14.14.37",
"@types/vue": "^2.0.0",
"@types/vue-clickaway": "^2.2.0",
"@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0",
"@vue/cli-plugin-babel": "^4.1.0",
"@vue/cli-plugin-eslint": "^4.1.0",
"@vue/cli-plugin-typescript": "^4.1.0",
"@vue/cli-plugin-vuex": "^4.1.0",
"@vue/cli-service": "^4.1.0",
"@typescript-eslint/eslint-plugin": "^4.19.0",
"@typescript-eslint/parser": "^4.19.0",
"@vue/cli-plugin-babel": "^4.5.6",
"@vue/cli-plugin-eslint": "^4.5.6",
"@vue/cli-plugin-typescript": "^4.5.6",
"@vue/cli-plugin-vuex": "^4.5.6",
"@vue/cli-service": "^4.5.12",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/eslint-config-typescript": "^5.0.2",
"@vue/eslint-config-typescript": "^7.0.0",
"core-js": "^3.9.1",
"eslint": "^6.8.0",
"eslint-plugin-prettier": "^3.1.1",
"eslint-plugin-vue": "^6.2.2",
"node-sass": "^4.12.0",
"prettier": "^2.0.2",
"sass-loader": "^8.0.0",
"ts-node": "^8.6.2",
"typescript": "^3.8.3",
"vue-template-compiler": "^2.6.10"
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-vue": "^7.8.0",
"node-sass": "^5.0.0",
"prettier": "^2.1.2",
"sass-loader": "^10.1.1",
"ts-node": "^9.1.1",
"typescript": "^4.2.3",
"vue-template-compiler": "^2.6.12"
}
}

View File

@ -5,13 +5,14 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>n.eko</title>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#19bd9c">
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="mask-icon" href="safari-pinned-tab.svg" color="#19bd9c">
<meta name="msapplication-TileColor" content="#19bd9c">
<meta name="theme-color" content="#19bd9c">
<style> /* weird iOS bug, if this is not set right here, video just does not start */ .video-container { width: 100%; height: 100%; } </style>
</head>
<body>
<noscript>

View File

@ -29,7 +29,7 @@
<neko-side v-if="side" />
<neko-connect v-if="!connected" />
<neko-about v-if="about" />
<notifications group="neko" position="top left" style="top: 50px;" />
<notifications group="neko" position="top left" :ignoreDuplicates="true" style="top: 50px;pointer-events: none" />
</template>
</div>
</template>
@ -109,25 +109,29 @@
}
@media only screen and (max-width: 600px) {
#neko {
&.expanded {
.neko-main {
transform: translateX(-$side-width);
}
.neko-menu {
transform: translateX(-$side-width);
#neko.expanded {
.neko-main {
transform: translateX(calc(-100% + 65px));
video {
display: none;
}
}
.neko-menu {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 65px;
width: calc(100% - 65px);
}
}
}
@media only screen and (max-width: 768px) {
#neko {
.neko-main {
.room-container {
display: none;
}
}
#neko .neko-main .room-container {
display: none;
}
}
</style>

View File

@ -0,0 +1,58 @@
<template>
<!--
<img :src="`https://ui-avatars.com/api/?name=${seed}&size=${size}`" />
-->
<div
class="avatar"
:style="{
width: size + 'px',
height: size + 'px',
lineHeight: size + 'px',
fontSize: size / 2 + 'px',
backgroundColor: Background(seed),
}"
>
{{ seed.substring(0, 2).toUpperCase() }}
</div>
</template>
<style lang="scss" scoped>
.avatar {
user-select: none;
text-align: center;
background: white;
color: black;
display: inline-block;
overflow: hidden;
border-radius: 50%;
}
</style>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
@Component({
name: 'neko-avatar',
})
export default class extends Vue {
@Prop(String) readonly seed: string | undefined
@Prop(Number) readonly size: number | undefined
Background(seed: string) {
let a = 0,
b = 0,
c = 0
for (let i = 0; i < seed.length; i++) {
a += seed.charCodeAt(i) * 3
b += seed.charCodeAt(i) * 5
c += seed.charCodeAt(i) * 7
}
let x = Math.floor(128 + (a % 128))
let y = Math.floor(128 + (b % 128))
let z = Math.floor(128 + (c % 128))
return 'rgb(' + x + ',' + y + ',' + z + ')'
}
}
</script>

View File

@ -4,7 +4,7 @@
<template v-for="(message, index) in history">
<li :key="index" class="message" v-if="message.type === 'text'">
<div class="author" @contextmenu.stop.prevent="onContext($event, { member: member(message.id) })">
<img :src="`https://ui-avatars.com/api/?name=${member(message.id).displayname}.png&size=40`" />
<neko-avatar class="avatar" :seed="member(message.id).displayname" :size="40" />
</div>
<div class="content">
<div class="content-head">
@ -106,7 +106,7 @@
background: $style-primary;
margin: 0px 10px 10px 0px;
img {
.avatar {
width: 100%;
}
}
@ -329,6 +329,7 @@
import Markdown from './markdown'
import Content from './context.vue'
import Emoji from './emoji.vue'
import Avatar from './avatar.vue'
const length = 512 // max length of message
@ -338,6 +339,7 @@
'neko-markdown': Markdown,
'neko-context': Content,
'neko-emoji': Emoji,
'neko-avatar': Avatar,
},
})
export default class extends Vue {

View File

@ -0,0 +1,75 @@
<template>
<div class="clipboard" v-if="opened" @click="$event.stopPropagation()">
<textarea ref="textarea" v-model="clipboard" @focus="$event.target.select()" />
</div>
</template>
<style lang="scss" scoped>
.clipboard {
background-color: $background-primary;
border-radius: 0.25rem;
display: block;
padding: 5px;
position: absolute;
bottom: 10px;
right: 10px;
&,
textarea {
max-width: 320px;
width: 100%;
max-height: 120px;
height: 100%;
}
textarea {
border: 0;
color: $text-normal;
background: none;
&::selection {
background: $text-normal;
}
}
}
</style>
<script lang="ts">
import { Component, Ref, Vue } from 'vue-property-decorator'
@Component({
name: 'neko-clipboard',
})
export default class extends Vue {
@Ref('textarea') readonly _textarea!: HTMLTextAreaElement
private opened: boolean = false
private typing: any = null
get clipboard() {
return this.$accessor.remote.clipboard
}
set clipboard(data: string) {
this.$accessor.remote.setClipboard(data)
if (this.typing) {
clearTimeout(this.typing)
}
this.typing = setTimeout(() => this.$accessor.remote.sendClipboard(this.clipboard), 500)
}
open() {
this.opened = true
document.body.addEventListener('click', this.close)
setTimeout(() => this._textarea.focus(), 0)
}
close() {
this.opened = false
document.body.removeEventListener('click', this.close)
}
}
</script>

View File

@ -6,9 +6,10 @@
<span><b>n</b>.eko</span>
</div>
<form class="message" v-if="!connecting" @submit.stop.prevent="connect">
<span>{{ $t('connect.title') }}</span>
<span v-if="!autoPassword">{{ $t('connect.login_title') }}</span>
<span v-else>{{ $t('connect.invitation_title') }}</span>
<input type="text" :placeholder="$t('connect.displayname')" v-model="displayname" />
<input type="password" :placeholder="$t('connect.password')" v-model="password" />
<input type="password" :placeholder="$t('connect.password')" v-model="password" v-if="!autoPassword" />
<button type="submit" @click.stop.prevent="login">
{{ $t('connect.connect') }}
</button>
@ -150,12 +151,21 @@
@Component({ name: 'neko-connect' })
export default class extends Vue {
private displayname = ''
private password = ''
private autoPassword: string | null = new URL(location.href).searchParams.get('pwd')
private displayname: string = ''
private password: string = ''
mounted() {
if (this.$accessor.displayname !== '' && this.$accessor.password !== '') {
this.$accessor.login({ displayname: this.$accessor.displayname, password: this.$accessor.password })
let password = this.$accessor.password
if (this.autoPassword !== null) {
this.removeUrlParam('pwd')
password = this.autoPassword
}
if (this.$accessor.displayname !== '' && password !== '') {
this.$accessor.login({ displayname: this.$accessor.displayname, password })
this.autoPassword = null
}
}
@ -163,8 +173,44 @@
return this.$accessor.connecting
}
login() {
this.$accessor.login({ displayname: this.displayname, password: this.password })
removeUrlParam(param: string) {
let url = document.location.href
let urlparts = url.split('?')
if (urlparts.length >= 2) {
let urlBase = urlparts.shift()
let queryString = urlparts.join('?')
let prefix = encodeURIComponent(param) + '='
let pars = queryString.split(/[&;]/g)
for (let i = pars.length; i-- > 0; ) {
if (pars[i].lastIndexOf(prefix, 0) !== -1) {
pars.splice(i, 1)
}
}
url = urlBase + (pars.length > 0 ? '?' + pars.join('&') : '')
window.history.pushState('', document.title, url)
}
}
async login() {
let password = this.password
if (this.autoPassword !== null) {
password = this.autoPassword
}
try {
await this.$accessor.login({ displayname: this.displayname, password })
this.autoPassword = null
} catch (err) {
this.$swal({
title: this.$t('connect.error') as string,
text: err.message,
icon: 'error',
})
}
}
}
</script>

View File

@ -3,7 +3,7 @@
<template slot-scope="child" v-if="child.data">
<li class="header">
<div class="user">
<img :src="`https://ui-avatars.com/api/?name=${child.data.member.displayname}.png&size=25`" />
<neko-avatar class="avatar" :seed="child.data.member.displayname" :size="25" />
<strong>{{ child.data.member.displayname }}</strong>
</div>
</li>
@ -39,10 +39,10 @@
<template v-if="admin && !child.data.member.admin">
<li class="seperator" />
<li>
<span @click="kick(child.data.member)" style="color: #f04747;">{{ $t('context.kick') }}</span>
<span @click="kick(child.data.member)" style="color: #f04747">{{ $t('context.kick') }}</span>
</li>
<li>
<span @click="ban(child.data.member)" style="color: #f04747;">{{ $t('context.ban') }}</span>
<span @click="ban(child.data.member)" style="color: #f04747">{{ $t('context.ban') }}</span>
</li>
</template>
</template>
@ -80,7 +80,7 @@
align-content: center;
padding: 5px 0;
img {
.avatar {
width: 25px;
height: 25px;
border-radius: 50%;
@ -137,11 +137,13 @@
// @ts-ignore
import { VueContext } from 'vue-context'
import Avatar from './avatar.vue'
@Component({
name: 'neko-context',
components: {
'vue-context': VueContext,
'neko-avatar': Avatar,
},
})
export default class extends Vue {

View File

@ -4,7 +4,7 @@
<ul class="members-list">
<li v-if="member">
<div :class="[{ host: member.id === host }, 'self', 'member']">
<img :src="`https://ui-avatars.com/api/?name=${member.displayname}.png&size=50`" />
<neko-avatar class="avatar" :seed="member.displayname" :size="50" />
</div>
</li>
<template v-for="(member, index) in members">
@ -13,11 +13,11 @@
:key="index"
v-tooltip="{ content: member.displayname, placement: 'bottom', offset: -15, boundariesElement: 'body' }"
>
<div :class="[{ host: member.id === host, admin: member.admin }, 'member']">
<img
:src="`https://ui-avatars.com/api/?name=${member.displayname}.png&size=50`"
@contextmenu.stop.prevent="onContext($event, { member })"
/>
<div
:class="[{ host: member.id === host, admin: member.admin }, 'member']"
@contextmenu.stop.prevent="onContext($event, { member })"
>
<neko-avatar class="avatar" :seed="member.displayname" :size="50" />
</div>
</li>
</template>
@ -130,7 +130,7 @@
border-radius: 50%;
}
img {
.avatar {
border-radius: 50%;
overflow: hidden;
width: 100%;
@ -161,11 +161,13 @@
import { Member } from '~/neko/types'
import Content from './context.vue'
import Avatar from './avatar.vue'
@Component({
name: 'neko-members',
components: {
'neko-context': Content,
'neko-avatar': Avatar,
},
})
export default class extends Vue {

View File

@ -44,6 +44,19 @@
<span />
</label>
</li>
<template v-if="admin">
<li>
<span>{{ $t('setting.broadcast_is_active') }}</span>
<label class="switch">
<input type="checkbox" v-model="broadcast_is_active" />
<span />
</label>
</li>
<li>
<span>{{ $t('setting.broadcast_url') }}</span>
<input v-model="broadcast_url" :disabled="broadcast_is_active" class="input" />
</li>
</template>
<li v-if="connected">
<button @click.stop.prevent="logout">{{ $t('logout') }}</button>
</li>
@ -229,6 +242,30 @@
}
}
}
.input {
display: block;
height: 30px;
text-align: right;
padding: 0 10px;
margin-left: 10px;
line-height: 30px;
text-overflow: ellipsis;
border: 1px solid transparent;
border-radius: 5px;
color: white;
background-color: $background-tertiary;
font-weight: lighter;
user-select: auto;
&::selection {
background: $text-normal;
}
&[disabled] {
background: none;
}
}
}
}
}
@ -239,6 +276,12 @@
@Component({ name: 'neko-settings' })
export default class extends Vue {
private broadcast_url: string = ''
get admin() {
return this.$accessor.user.admin
}
get connected() {
return this.$accessor.connected
}
@ -291,6 +334,27 @@
return this.$accessor.settings.keyboard_layout
}
get broadcast_is_active() {
return this.$accessor.settings.broadcast_is_active
}
set broadcast_is_active(value: boolean) {
if (value) {
this.$accessor.settings.broadcastCreate(this.broadcast_url)
} else {
this.$accessor.settings.broadcastDestroy()
}
}
get broadcast_url_remote() {
return this.$accessor.settings.broadcast_url
}
@Watch('broadcast_url_remote', { immediate: true })
onBroadcastUrlChange() {
this.broadcast_url = this.broadcast_url_remote
}
set keyboard_layout(value: string) {
this.$accessor.settings.setKeyboardLayout(value)
this.$accessor.remote.changeKeyboard()

View File

@ -2,7 +2,7 @@
<div ref="component" class="video">
<div ref="player" class="player">
<div ref="container" class="player-container">
<video ref="video" />
<video ref="video" playsinline />
<div class="emotes">
<template v-for="(emote, index) in emotes">
<neko-emote :id="index" :key="index" />
@ -26,7 +26,7 @@
</div>
<div ref="aspect" class="player-aspect" />
</div>
<ul v-if="!fullscreen" class="video-menu">
<ul v-if="!fullscreen" class="video-menu top">
<li><i @click.stop.prevent="requestFullscreen" class="fas fa-expand"></i></li>
<li v-if="admin"><i @click.stop.prevent="onResolution" class="fas fa-desktop"></i></li>
<li class="request-control">
@ -36,7 +36,21 @@
/>
</li>
</ul>
<neko-resolution ref="resolution" />
<ul v-if="!fullscreen" class="video-menu bottom">
<li v-if="hosting && (!clipboard_read_available || !clipboard_write_available)">
<i @click.stop.prevent="onClipboard" class="fas fa-clipboard"></i>
</li>
<li>
<i
v-if="pip_available"
@click.stop.prevent="requestPictureInPicture"
v-tooltip="{ content: 'Picture-in-Picture', placement: 'left', offset: 5, boundariesElement: 'body' }"
class="fas fa-external-link-alt"
/>
</li>
</ul>
<neko-resolution ref="resolution" v-if="admin" />
<neko-clipboard ref="clipboard" v-if="hosting && (!clipboard_read_available || !clipboard_write_available)" />
</div>
</div>
</template>
@ -55,7 +69,14 @@
.video-menu {
position: absolute;
right: 20px;
top: 15px;
&.top {
top: 15px;
}
&.bottom {
bottom: 15px;
}
li {
margin: 0 0 10px 0;
@ -89,6 +110,10 @@
display: inline-block;
}
}
&:last-child {
margin: 0;
}
}
}
@ -163,7 +188,9 @@
import Emote from './emote.vue'
import Resolution from './resolution.vue'
import Clipboard from './clipboard.vue'
// @ts-ignore
import GuacamoleKeyboard from '~/utils/guacamole-keyboard.ts'
@Component({
@ -171,6 +198,7 @@
components: {
'neko-emote': Emote,
'neko-resolution': Resolution,
'neko-clipboard': Clipboard,
},
})
export default class extends Vue {
@ -181,6 +209,7 @@
@Ref('player') readonly _player!: HTMLElement
@Ref('video') readonly _video!: HTMLVideoElement
@Ref('resolution') readonly _resolution!: any
@Ref('clipboard') readonly _clipboard!: any
private keyboard = GuacamoleKeyboard()
private observer = new ResizeObserver(this.onResise.bind(this))
@ -247,6 +276,19 @@
return this.$accessor.settings.scroll_invert
}
get pip_available() {
//@ts-ignore
return typeof document.createElement('video').requestPictureInPicture === 'function'
}
get clipboard_read_available() {
return 'clipboard' in navigator && typeof navigator.clipboard.readText === 'function'
}
get clipboard_write_available() {
return 'clipboard' in navigator && typeof navigator.clipboard.writeText === 'function'
}
get clipboard() {
return this.$accessor.remote.clipboard
}
@ -320,7 +362,7 @@
@Watch('clipboard')
onClipboardChanged(clipboard: string) {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
if (this.clipboard_write_available) {
navigator.clipboard.writeText(clipboard).catch(console.error)
}
}
@ -362,7 +404,6 @@
})
document.addEventListener('focusin', this.onFocus.bind(this))
document.addEventListener('focusout', this.onBlur.bind(this))
/* Initialize Guacamole Keyboard */
this.keyboard.onkeydown = (key: number) => {
@ -387,7 +428,6 @@
this.observer.disconnect()
this.$accessor.video.setPlayable(false)
document.removeEventListener('focusin', this.onFocus.bind(this))
document.removeEventListener('focusout', this.onBlur.bind(this))
/* Guacamole Keyboard does not provide destroy functions */
}
@ -437,7 +477,28 @@
}
requestFullscreen() {
this._player.requestFullscreen()
if (typeof this._player.requestFullscreen === 'function') {
this._player.requestFullscreen()
//@ts-ignore
} else if (typeof this._player.webkitRequestFullscreen === 'function') {
//@ts-ignore
this._player.webkitRequestFullscreen()
//@ts-ignore
} else if (typeof this._player.webkitEnterFullscreen === 'function') {
//@ts-ignore
this._player.webkitEnterFullscreen()
//@ts-ignore
} else if (typeof this._player.msRequestFullScreen === 'function') {
//@ts-ignore
this._player.msRequestFullScreen()
}
this.onResise()
}
requestPictureInPicture() {
//@ts-ignore
this._video.requestPictureInPicture()
this.onResise()
}
@ -446,7 +507,7 @@
return
}
if (this.hosting && navigator.clipboard && typeof navigator.clipboard.readText === 'function') {
if (this.hosting && this.clipboard_read_available) {
navigator.clipboard
.readText()
.then((text) => {
@ -459,14 +520,6 @@
}
}
onBlur() {
if (!this.focused || !this.hosting || this.locked) {
return
}
this.keyboard.reset()
}
onMousePos(e: MouseEvent) {
const { w, h } = this.$accessor.video.resolution
const rect = this._overlay.getBoundingClientRect()
@ -516,16 +569,34 @@
if (!this.hosting || this.locked) {
return
}
this.onMousePos(e)
}
onMouseEnter(e: MouseEvent) {
if (this.hosting) {
this.$accessor.remote.syncKeyboardModifierState({
capsLock: e.getModifierState('CapsLock'),
numLock: e.getModifierState('NumLock'),
scrollLock: e.getModifierState('ScrollLock'),
})
}
this._overlay.focus()
this.onFocus()
this.focused = true
}
onMouseLeave(e: MouseEvent) {
if (this.hosting) {
this.$accessor.remote.setKeyboardModifierState({
capsLock: e.getModifierState('CapsLock'),
numLock: e.getModifierState('NumLock'),
scrollLock: e.getModifierState('ScrollLock'),
})
}
this.keyboard.reset()
this.focused = false
}
@ -548,5 +619,9 @@
onResolution(event: MouseEvent) {
this._resolution.open(event)
}
onClipboard(event: MouseEvent) {
this._clipboard.open(event)
}
}
</script>

65
client/src/lib.ts Normal file
View File

@ -0,0 +1,65 @@
import { accessor as neko } from './store'
import { PluginObject } from 'vue'
// Plugins
import Logger from './plugins/log'
import Client from './plugins/neko'
import Axios from './plugins/axios'
import Swal from './plugins/swal'
import Anime from './plugins/anime'
import { i18n } from './plugins/i18n'
// Components
import Connect from '~/components/connect.vue'
import Video from '~/components/video.vue'
import Menu from '~/components/menu.vue'
import Side from '~/components/side.vue'
import Controls from '~/components/controls.vue'
import Members from '~/components/members.vue'
import Emotes from '~/components/emotes.vue'
import About from '~/components/about.vue'
import Header from '~/components/header.vue'
const exportMixin = {
computed: {
$accessor() {
return neko
},
$client () {
return window.$client
}
},
}
const plugini18n: PluginObject<undefined> = {
install(Vue) {
Vue.prototype.i18n = i18n
Vue.prototype.$t = i18n.t.bind(i18n)
},
}
function extend (component: any) {
return component
.use(plugini18n)
.use(Logger)
.use(Axios)
.use(Swal)
.use(Anime)
.use(Client)
.extend(exportMixin)
}
export const components = {
'neko-connect': extend(Connect),
'neko-video': extend(Video),
'neko-menu': extend(Menu),
'neko-side': extend(Side),
'neko-controls': extend(Controls),
'neko-members': extend(Members),
'neko-emotes': extend(Emotes),
'neko-about': extend(About),
'neko-header': extend(Header),
}
neko.initialise()
export default neko

View File

@ -10,10 +10,12 @@ export const side = {
}
export const connect = {
title: 'Please Login',
displayname: 'Display Name',
login_title: 'Please Login',
invitation_title: 'You have been invited to this room',
displayname: 'Enter your display name',
password: 'Password',
connect: 'Connect',
error: 'Login error',
}
export const context = {
@ -48,10 +50,10 @@ export const controls = {
}
export const room = {
lock: 'Lock Room',
unlock: 'Unlock Room',
locked: 'Room Locked',
unlocked: 'Room Unlocked',
lock: 'Lock Room (for users)',
unlock: 'Unlock Room (for users)',
locked: 'Room Locked (for users)',
unlocked: 'Room Unlocked (for users)',
}
export const setting = {
@ -61,6 +63,8 @@ export const setting = {
ignore_emotes: 'Ignore Emotes',
chat_sound: 'Play Chat Sound',
keyboard_layout: 'Keyboard Layout',
broadcast_is_active: 'Broadcast Enabled',
broadcast_url: 'RTMP url',
}
export const connection = {

View File

@ -2,7 +2,7 @@ import EventEmitter from 'eventemitter3'
import { OPCODE } from './data'
import { EVENT, WebSocketEvents } from './events'
import { WebSocketMessages, WebSocketPayloads, SignalProvidePayload } from './messages'
import { WebSocketMessages, WebSocketPayloads, SignalProvidePayload, SignalCandidatePayload } from './messages'
export interface BaseEvents {
info: (...message: any[]) => void
@ -19,6 +19,7 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
protected _displayname?: string
protected _state: RTCIceConnectionState = 'disconnected'
protected _id = ''
protected _candidates: RTCIceCandidate[] = []
get id() {
return this._id
@ -52,18 +53,18 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
}
if (displayname === '') {
throw new Error('Must add a displayname') // TODO: Better handling
throw new Error('Display Name cannot be empty.')
}
this._displayname = displayname
this[EVENT.CONNECTING]()
try {
this._ws = new WebSocket(`${url}ws?password=${password}`)
this._ws = new WebSocket(`${url}?password=${encodeURIComponent(password)}`)
this.emit('debug', `connecting to ${this._ws.url}`)
this._ws.onmessage = this.onMessage.bind(this)
this._ws.onerror = event => this.onError.bind(this)
this._ws.onclose = event => this.onDisconnected.bind(this, new Error('websocket closed'))
this._ws.onerror = (event) => this.onError.bind(this)
this._ws.onclose = (event) => this.onDisconnected.bind(this, new Error('websocket closed'))
this._timeout = setTimeout(this.onTimeout.bind(this), 15000)
} catch (err) {
this.onDisconnected(err)
@ -75,17 +76,43 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
clearTimeout(this._timeout)
}
if (this.socketOpen) {
if (this._ws) {
// reset all events
this._ws.onmessage = () => {}
this._ws.onerror = () => {}
this._ws.onclose = () => {}
try {
this._ws!.close()
this._ws.close()
} catch (err) {}
this._ws = undefined
}
if (this.peerConnected) {
if (this._channel) {
// reset all events
this._channel.onmessage = () => {}
this._channel.onerror = () => {}
this._channel.onclose = () => {}
try {
this._peer!.close()
this._channel.close()
} catch (err) {}
this._channel = undefined
}
if (this._peer) {
// reset all events
this._peer.onconnectionstatechange = () => {}
this._peer.onsignalingstatechange = () => {}
this._peer.oniceconnectionstatechange = () => {}
this._peer.ontrack = () => {}
try {
this._peer.close()
} catch (err) {}
this._peer = undefined
}
@ -179,15 +206,15 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
})
}
this._peer.onconnectionstatechange = event => {
this._peer.onconnectionstatechange = (event) => {
this.emit('debug', `peer connection state changed`, this._peer ? this._peer.connectionState : undefined)
}
this._peer.onsignalingstatechange = event => {
this._peer.onsignalingstatechange = (event) => {
this.emit('debug', `peer signaling state changed`, this._peer ? this._peer.signalingState : undefined)
}
this._peer.oniceconnectionstatechange = event => {
this._peer.oniceconnectionstatechange = (event) => {
this._state = this._peer!.iceConnectionState
this.emit('debug', `peer ice connection state changed: ${this._peer!.iceConnectionState}`)
@ -220,9 +247,15 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
this._channel.onclose = this.onDisconnected.bind(this, new Error('peer data channel closed'))
this._peer.setRemoteDescription({ type: 'offer', sdp })
for (const candidate of this._candidates) {
this._peer.addIceCandidate(candidate)
}
this._candidates = []
this._peer
.createAnswer()
.then(d => {
.then((d) => {
this._peer!.setLocalDescription(d)
this._ws!.send(
JSON.stringify({
@ -232,7 +265,7 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
}),
)
})
.catch(err => this.emit('error', err))
.catch((err) => this.emit('error', err))
}
private onMessage(e: MessageEvent) {
@ -247,6 +280,17 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
return
}
if (event === EVENT.SIGNAL.CANDIDATE) {
const { data } = payload as SignalCandidatePayload
const candidate: RTCIceCandidate = JSON.parse(data)
if (this._peer) {
this._peer.addIceCandidate(candidate)
} else {
this._candidates.push(candidate)
}
return
}
// @ts-ignore
if (typeof this[event] === 'function') {
// @ts-ignore

View File

@ -14,6 +14,7 @@ export const EVENT = {
SIGNAL: {
ANSWER: 'signal/answer',
PROVIDE: 'signal/provide',
CANDIDATE: 'signal/candidate',
},
MEMBER: {
LIST: 'member/list',
@ -27,7 +28,7 @@ export const EVENT = {
REQUESTING: 'control/requesting',
CLIPBOARD: 'control/clipboard',
GIVE: 'control/give',
KEYBOARD: 'control/keyboard'
KEYBOARD: 'control/keyboard',
},
CHAT: {
MESSAGE: 'chat/message',
@ -38,6 +39,11 @@ export const EVENT = {
RESOLUTION: 'screen/resolution',
SET: 'screen/set',
},
BROADCAST: {
STATUS: 'broadcast/status',
CREATE: 'broadcast/create',
DESTROY: 'broadcast/destroy',
},
ADMIN: {
BAN: 'admin/ban',
KICK: 'admin/kick',
@ -60,6 +66,7 @@ export type WebSocketEvents =
| SignalEvents
| ChatEvents
| ScreenEvents
| BroadcastEvents
| AdminEvents
export type ControlEvents =
@ -72,10 +79,15 @@ export type ControlEvents =
export type SystemEvents = typeof EVENT.SYSTEM.DISCONNECT
export type MemberEvents = typeof EVENT.MEMBER.LIST | typeof EVENT.MEMBER.CONNECTED | typeof EVENT.MEMBER.DISCONNECTED
export type SignalEvents = typeof EVENT.SIGNAL.ANSWER | typeof EVENT.SIGNAL.PROVIDE
export type SignalEvents = typeof EVENT.SIGNAL.ANSWER | typeof EVENT.SIGNAL.PROVIDE | typeof EVENT.SIGNAL.CANDIDATE
export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
export type ScreenEvents = typeof EVENT.SCREEN.CONFIGURATIONS | typeof EVENT.SCREEN.RESOLUTION | typeof EVENT.SCREEN.SET
export type BroadcastEvents =
| typeof EVENT.BROADCAST.STATUS
| typeof EVENT.BROADCAST.CREATE
| typeof EVENT.BROADCAST.DESTROY
export type AdminEvents =
| typeof EVENT.ADMIN.BAN
| typeof EVENT.ADMIN.KICK

View File

@ -18,6 +18,7 @@ import {
ControlClipboardPayload,
ScreenConfigurationsPayload,
ScreenResolutionPayload,
BroadcastStatusPayload,
AdminPayload,
AdminTargetPayload,
} from './messages'
@ -27,10 +28,21 @@ interface NekoEvents extends BaseEvents {}
export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
private $vue!: Vue
private $accessor!: typeof accessor
private url!: string
init(vue: Vue) {
const url =
process.env.NODE_ENV === 'development'
? `ws://${location.host.split(':')[0]}:${process.env.VUE_APP_SERVER_PORT}/ws`
: location.protocol.replace(/^http/, 'ws') + '//' + location.host + location.pathname.replace(/\/$/, '') + '/ws'
this.initWithURL(vue, url)
}
initWithURL(vue: Vue, url: string) {
this.$vue = vue
this.$accessor = vue.$accessor
this.url = url
}
private cleanup() {
@ -42,12 +54,7 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
}
login(password: string, displayname: string) {
const url =
process.env.NODE_ENV === 'development'
? `ws://${location.host.split(':')[0]}:${process.env.VUE_APP_SERVER_PORT}/`
: `${/https/gi.test(location.protocol) ? 'wss' : 'ws'}://${location.host}/`
this.connect(url, password, displayname)
this.connect(this.url, password, displayname)
}
logout() {
@ -83,6 +90,11 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
protected [EVENT.DISCONNECTED](reason?: Error) {
this.cleanup()
if (reason && reason.message == 'kicked') {
this.$accessor.logout()
}
this.$vue.$notify({
group: 'neko',
type: 'error',
@ -326,6 +338,13 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
})
}
/////////////////////////////
// Broadcast Events
/////////////////////////////
protected [EVENT.BROADCAST.STATUS](payload: BroadcastStatusPayload) {
this.$accessor.settings.broadcastStatus(payload)
}
/////////////////////////////
// Admin Events
/////////////////////////////

View File

@ -15,6 +15,7 @@ export type WebSocketMessages =
| WebSocketMessage
| SignalProvideMessage
| SignalAnswerMessage
| SignalCandidateMessage
| MemberListMessage
| MemberConnectMessage
| MemberDisconnectMessage
@ -26,6 +27,7 @@ export type WebSocketMessages =
export type WebSocketPayloads =
| SignalProvidePayload
| SignalAnswerPayload
| SignalCandidatePayload
| MemberListPayload
| Member
| ControlPayload
@ -37,6 +39,8 @@ export type WebSocketPayloads =
| ScreenResolutionPayload
| ScreenConfigurationsPayload
| AdminPayload
| BroadcastStatusPayload
| BroadcastCreatePayload
export interface WebSocketMessage {
event: WebSocketEvents | string
@ -76,6 +80,14 @@ export interface SignalAnswerPayload {
displayname: string
}
// signal/candidate
export interface SignalCandidateMessage extends WebSocketMessage, SignalCandidatePayload {
event: typeof EVENT.SIGNAL.CANDIDATE
}
export interface SignalCandidatePayload {
data: string
}
/*
MEMBER MESSAGES/PAYLOADS
*/
@ -122,7 +134,10 @@ export interface ControlClipboardPayload {
}
export interface ControlKeyboardPayload {
layout: string
layout?: string
capsLock?: boolean
numLock?: boolean
scrollLock?: boolean
}
/*
@ -174,6 +189,18 @@ export interface ScreenConfigurationsPayload {
configurations: ScreenConfigurations
}
/*
BROADCAST PAYLOADS
*/
export interface BroadcastCreatePayload {
url: string
}
export interface BroadcastStatusPayload {
url: string
isActive: boolean
}
/*
ADMIN PAYLOADS
*/

View File

@ -25,10 +25,10 @@ class VueSweetalert2 {
if (options) {
const mixed = Swal.mixin(options)
return mixed.fire.apply(mixed, args)
return mixed.fire(...args)
}
return Swal.fire.apply(Swal, args)
return Swal.fire(...args)
}
let methodName: string | number | symbol
@ -40,7 +40,7 @@ class VueSweetalert2 {
swalFunction[methodName] = ((method) => {
return (...args: any[]) => {
// @ts-ignore
return Swal[method].apply(Swal, args)
return Swal[method](...args)
}
})(methodName)
}

View File

@ -60,7 +60,7 @@ export const actions = actionTree(
{
initialise() {
$http
.get<Emojis>('/emoji.json')
.get<Emojis>('emoji.json')
.then((req) => {
for (const group of req.data.groups) {
accessor.emoji.addGroup(group)

View File

@ -3,12 +3,17 @@ import { Member } from '~/neko/types'
import { EVENT } from '~/neko/events'
import { accessor } from '~/store'
const keyboardModifierState = (capsLock: boolean, numLock: boolean, scrollLock: boolean) =>
Number(capsLock) + 2 * Number(numLock) + 4 * Number(scrollLock)
export const namespaced = true
export const state = () => ({
id: '',
clipboard: '',
locked: false,
keyboardModifierState: -1,
})
export const getters = getterTree(state, {
@ -36,6 +41,10 @@ export const mutations = mutationTree(state, {
state.clipboard = clipboard
},
setKeyboardModifierState(state, { capsLock, numLock, scrollLock }) {
state.keyboardModifierState = keyboardModifierState(capsLock, numLock, scrollLock)
},
setLocked(state, locked: boolean) {
state.locked = locked
},
@ -44,6 +53,7 @@ export const mutations = mutationTree(state, {
state.id = ''
state.clipboard = ''
state.locked = false
state.keyboardModifierState = -1
},
})
@ -140,6 +150,15 @@ export const actions = actionTree(
}
$client.sendMessage(EVENT.CONTROL.KEYBOARD, { layout: accessor.settings.keyboard_layout })
}
},
syncKeyboardModifierState({ state, getters }, { capsLock, numLock, scrollLock }) {
if (state.keyboardModifierState === keyboardModifierState(capsLock, numLock, scrollLock)) {
return
}
accessor.remote.setKeyboardModifierState({ capsLock, numLock, scrollLock })
$client.sendMessage(EVENT.CONTROL.KEYBOARD, { capsLock, numLock, scrollLock })
},
},
)

View File

@ -1,5 +1,6 @@
import { getterTree, mutationTree, actionTree } from 'typed-vuex'
import { get, set } from '~/utils/localstorage'
import { EVENT } from '~/neko/events'
import { accessor } from '~/store'
export const namespaced = true
@ -18,6 +19,9 @@ export const state = () => {
keyboard_layout: get<string>('keyboard_layout', 'us'),
keyboard_layouts_list: {} as KeyboardLayouts,
broadcast_is_active: false,
broadcast_url: '',
}
}
@ -57,6 +61,10 @@ export const mutations = mutationTree(state, {
setKeyboardLayoutsList(state, value: KeyboardLayouts) {
state.keyboard_layouts_list = value
},
setBroadcastStatus(state, { url, isActive }) {
state.broadcast_url = url
state.broadcast_is_active = isActive
},
})
export const actions = actionTree(
@ -64,12 +72,21 @@ export const actions = actionTree(
{
initialise() {
$http
.get<KeyboardLayouts>('/keyboard_layouts.json')
.get<KeyboardLayouts>('keyboard_layouts.json')
.then((req) => {
accessor.settings.setKeyboardLayoutsList(req.data)
console.log(req.data)
})
.catch(console.error)
},
broadcastStatus({ getters }, { url, isActive }) {
accessor.settings.setBroadcastStatus({ url, isActive })
},
broadcastCreate({ getters }, url: string) {
$client.sendMessage(EVENT.BROADCAST.CREATE, { url })
},
broadcastDestroy({ getters }) {
$client.sendMessage(EVENT.BROADCAST.DESTROY)
},
},
)

View File

@ -48,6 +48,11 @@ export const mutations = mutationTree(state, {
state.id = id
},
addMember(state, member: Member) {
// remove html tags
const tmp = document.createElement('div')
tmp.innerHTML = member.displayname
member.displayname = tmp.textContent || tmp.innerText || ''
state.members = {
...state.members,
[member.id]: {

View File

@ -1,3 +1,4 @@
/* eslint-disable */
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file

View File

@ -4,41 +4,41 @@ export interface GuacamoleKeyboardInterface {
/**
* Fired whenever the user presses a key with the element associated
* with this Guacamole.Keyboard in focus.
*
*
* @event
* @param {Number} keysym The keysym of the key being pressed.
* @return {Boolean} true if the key event should be allowed through to the
* browser, false otherwise.
*/
onkeydown?: (keysym: number) => boolean;
onkeydown?: (keysym: number) => boolean
/**
* Fired whenever the user releases a key with the element associated
* with this Guacamole.Keyboard in focus.
*
*
* @event
* @param {Number} keysym The keysym of the key being released.
*/
onkeyup?: (keysym: number) => void;
onkeyup?: (keysym: number) => void
/**
* Marks a key as pressed, firing the keydown event if registered. Key
* repeat for the pressed key will start after a delay if that key is
* not a modifier. The return value of this function depends on the
* return value of the keydown event handler, if any.
*
*
* @param {Number} keysym The keysym of the key to press.
* @return {Boolean} true if event should NOT be canceled, false otherwise.
*/
press: (keysym: number) => boolean;
press: (keysym: number) => boolean
/**
* Marks a key as released, firing the keyup event if registered.
*
*
* @param {Number} keysym The keysym of the key to release.
*/
release: (keysym: number) => void;
release: (keysym: number) => void
/**
* Presses and releases the keys necessary to type the given string of
* text.
@ -46,14 +46,14 @@ export interface GuacamoleKeyboardInterface {
* @param {String} str
* The string to type.
*/
type: (str: string) => void;
type: (str: string) => void
/**
* Resets the state of this keyboard, releasing all keys, and firing keyup
* events for each released key.
*/
reset: () => void;
reset: () => void
/**
* Attaches event listeners to the given Element, automatically translating
* received key, input, and composition events into simple keydown/keyup
@ -64,13 +64,13 @@ export interface GuacamoleKeyboardInterface {
* The Element to attach event listeners to for the sake of handling
* key or input events.
*/
listenTo: (element: Element | Document) => void;
listenTo: (element: Element | Document) => void
}
export default function(element?: Element): GuacamoleKeyboardInterface {
var Keyboard = {};
export default function (element?: Element): GuacamoleKeyboardInterface {
const Keyboard = {}
GuacamoleKeyboard.bind(Keyboard, element)();
GuacamoleKeyboard.bind(Keyboard, element)()
return Keyboard as GuacamoleKeyboardInterface;
return Keyboard as GuacamoleKeyboardInterface
}

View File

@ -2,7 +2,7 @@ export function makeid(length: number) {
let result = ''
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const charactersLength = characters.length
for (var i = 0; i < length; i++) {
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength))
}
return result

View File

@ -13,7 +13,7 @@ export function set<T extends string | number | boolean>(key: string, val: T) {
}
export function get<T extends string | number | boolean>(key: string, def: T): T {
let store = localStorage.getItem(key)
const store = localStorage.getItem(key)
if (store) {
switch (typeof def) {
case 'number':

View File

@ -5,12 +5,14 @@ module.exports = {
css: {
loaderOptions: {
sass: {
prependData: `
additionalData: `
@import "@/assets/styles/_variables.scss";
`,
},
},
},
publicPath: './',
assetsDir: './',
configureWebpack: {
resolve: {
alias: {