more progress on refactor

This commit is contained in:
Craig 2020-01-23 15:23:26 +00:00
parent 8ba1b68a21
commit 157ee2e1fb
45 changed files with 1344 additions and 789 deletions

0
client/ABOUT.md Normal file
View File

View File

@ -21,7 +21,6 @@
"@fortawesome/fontawesome-free": "^5.12.0", "@fortawesome/fontawesome-free": "^5.12.0",
"animejs": "^3.1.0", "animejs": "^3.1.0",
"axios": "^0.19.1", "axios": "^0.19.1",
"bulma": "^0.8.0",
"date-fns": "^2.9.0", "date-fns": "^2.9.0",
"eventemitter3": "^4.0.0", "eventemitter3": "^4.0.0",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
@ -34,20 +33,17 @@
"vue-context": "^5.0.0", "vue-context": "^5.0.0",
"vue-notification": "^1.3.20", "vue-notification": "^1.3.20",
"vue-property-decorator": "^8.3.0", "vue-property-decorator": "^8.3.0",
"vue-router": "^3.1.5",
"vuex": "^3.1.2" "vuex": "^3.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/animejs": "^3.1.0", "@types/animejs": "^3.1.0",
"@types/vue": "^2.0.0", "@types/vue": "^2.0.0",
"@types/w3c-image-capture": "^1.0.2",
"@vue/cli-plugin-eslint": "^4.1.0", "@vue/cli-plugin-eslint": "^4.1.0",
"@vue/cli-plugin-typescript": "^4.1.0", "@vue/cli-plugin-typescript": "^4.1.0",
"@vue/cli-plugin-vuex": "^4.1.0", "@vue/cli-plugin-vuex": "^4.1.0",
"@vue/cli-service": "^4.1.0", "@vue/cli-service": "^4.1.0",
"@vue/eslint-config-prettier": "^5.0.0", "@vue/eslint-config-prettier": "^5.0.0",
"@vue/eslint-config-typescript": "^4.0.0", "@vue/eslint-config-typescript": "^4.0.0",
"autoprefixer": "^9.7.4",
"eslint": "^5.16.0", "eslint": "^5.16.0",
"eslint-plugin-prettier": "^3.1.1", "eslint-plugin-prettier": "^3.1.1",
"eslint-plugin-vue": "^5.0.0", "eslint-plugin-vue": "^5.0.0",

BIN
client/public/chat.mp3 Normal file

Binary file not shown.

View File

@ -18,6 +18,5 @@
<strong>We're sorry but test doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> <strong>We're sorry but test doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript> </noscript>
<div id="neko"></div> <div id="neko"></div>
<!-- built files will be auto injected -->
</body> </body>
</html> </html>

View File

@ -2,11 +2,7 @@
<div id="neko"> <div id="neko">
<main class="neko-main"> <main class="neko-main">
<div class="header-container"> <div class="header-container">
<div class="neko"> <neko-header />
<img src="@/assets/logo.svg" alt="n.eko" />
<span><b>n</b>.eko</span>
</div>
<i class="fas fa-bars toggle" @click="toggle" />
</div> </div>
<div class="video-container"> <div class="video-container">
<neko-video ref="video" /> <neko-video ref="video" />
@ -20,8 +16,8 @@
<div class="controls"> <div class="controls">
<neko-controls /> <neko-controls />
</div> </div>
<div class="emoji"> <div class="emotes">
<neko-emoji /> <neko-emotes />
</div> </div>
</div> </div>
</div> </div>
@ -29,7 +25,7 @@
<neko-side v-if="side" /> <neko-side v-if="side" />
<neko-connect v-if="!connected" /> <neko-connect v-if="!connected" />
<neko-about v-if="about" /> <neko-about v-if="about" />
<notifications group="neko" position="top left" /> <notifications group="neko" position="top left" style="top: 50px;" />
</div> </div>
</template> </template>
@ -55,44 +51,6 @@
height: $menu-height; height: $menu-height;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
.toggle {
display: block;
width: 30px;
height: 30px;
text-align: center;
line-height: 32px;
background: $background-primary;
justify-self: flex-end;
border-radius: 3px;
margin: 5px 10px 0 0;
cursor: pointer;
}
.neko {
flex: 1;
display: flex;
justify-content: flex-start;
align-items: center;
width: 150px;
margin-left: 20px;
img {
display: block;
float: left;
height: 30px;
margin-right: 10px;
}
span {
font-size: 30px;
line-height: 30px;
b {
font-weight: 900;
}
}
}
} }
.video-container { .video-container {
@ -130,7 +88,7 @@
display: flex; display: flex;
} }
.emoji { .emotes {
margin-right: 10px; margin-right: 10px;
flex: 1; flex: 1;
justify-content: flex-end; justify-content: flex-end;
@ -152,8 +110,9 @@
import Side from '~/components/side.vue' import Side from '~/components/side.vue'
import Controls from '~/components/controls.vue' import Controls from '~/components/controls.vue'
import Members from '~/components/members.vue' import Members from '~/components/members.vue'
import Emoji from '~/components/emoji.vue' import Emotes from '~/components/emotes.vue'
import About from '~/components/about.vue' import About from '~/components/about.vue'
import Header from '~/components/header.vue'
@Component({ @Component({
name: 'neko', name: 'neko',
@ -164,8 +123,9 @@
'neko-side': Side, 'neko-side': Side,
'neko-controls': Controls, 'neko-controls': Controls,
'neko-members': Members, 'neko-members': Members,
'neko-emoji': Emoji, 'neko-emotes': Emotes,
'neko-about': About, 'neko-about': About,
'neko-header': Header,
}, },
}) })
export default class extends Vue { export default class extends Vue {
@ -182,9 +142,5 @@
get connected() { get connected() {
return this.$accessor.connected return this.$accessor.connected
} }
toggle() {
this.$accessor.client.toggleSide()
}
} }
</script> </script>

View File

@ -12,7 +12,6 @@
@import "vendor/swal"; @import "vendor/swal";
@import "vendor/tooltip"; @import "vendor/tooltip";
@import "vendor/github"; @import "vendor/github";
// @import "vendor/bulma";
html, body { html, body {
-webkit-font-smoothing: subpixel-antialiased; -webkit-font-smoothing: subpixel-antialiased;

View File

@ -1,245 +0,0 @@
@import "~bulma/sass/utilities/initial-variables.sass";
@import "~bulma/sass/utilities/functions.sass";
@import "~bulma/sass/utilities/derived-variables.sass";
@import "~bulma/sass/utilities/animations.sass";
@import "~bulma/sass/utilities/mixins.sass";
@import "~bulma/sass/utilities/controls.sass";
// $black: hsl(0, 0%, 4%);
// $black-bis: hsl(0, 0%, 7%);
// $black-ter: hsl(0, 0%, 14%);
// $grey-darker: hsl(0, 0%, 21%);
// $grey-dark: hsl(0, 0%, 29%);
// $grey: hsl(0, 0%, 48%);
// $grey-light: hsl(0, 0%, 71%);
// $grey-lighter: hsl(0, 0%, 86%);
// $grey-lightest: hsl(0, 0%, 93%);
// $white-ter: hsl(0, 0%, 96%);
// $white-bis: hsl(0, 0%, 98%);
// $white: hsl(0, 0%, 100%);
// $orange: hsl(14, 100%, 53%);
// $yellow: hsl(48, 100%, 67%);
// $green: hsl(141, 53%, 53%);
// $turquoise: hsl(171, 100%, 41%);
// $cyan: hsl(204, 71%, 53%);
// $blue: hsl(217, 71%, 53%);
// $purple: hsl(271, 100%, 71%);
// $red: hsl(348, 86%, 61%);
// Typography
$family-sans-serif: $text-family;
// $family-monospace: monospace;
// $render-mode: optimizeLegibility;
// $size-1: 3rem;
// $size-2: 2.5rem;
// $size-3: 2rem;
// $size-4: 1.5rem;
// $size-5: 1.25rem;
// $size-6: 1rem;
// $size-7: 0.75rem;
// $weight-light: 300;
// $weight-normal: 400;
// $weight-medium: 500;
// $weight-semibold: 600;
// $weight-bold: 700;
// Spacing
// $block-spacing: 1.5rem;
// Responsiveness
// The container horizontal gap, which acts as the offset for breakpoints
// $gap: 32px;
// 960, 1152, and 1344 have been chosen because they are divisible by both 12 and 16
// $tablet: 769px;
// 960px container + 4rem
// $desktop: 960px + (2 * // $gap);
// 1152px container + 4rem
// $widescreen: 1152px + (2 * // $gap);
// $widescreen-enabled: true;
// 1344px container + 4rem
// $fullhd: 1344px + (2 * // $gap);
// $fullhd-enabled: true;
// Miscellaneous
// $easing: ease-out;
// $radius-small: 2px;
// $radius: 4px;
// $radius-large: 6px;
// $radius-rounded: 290486px;
// $speed: 86ms;
// Flags
// $variable-columns: true;
// $primary: $turquoise;
// $info: $cyan;
// $success: $green;
// $warning: $yellow;
// $danger: $red;
// $light: $white-ter;
// $dark: $grey-darker;
// Invert colors
// $orange-invert: findColorInvert($orange);
// $yellow-invert: findColorInvert($yellow);
// $green-invert: findColorInvert($green);
// $turquoise-invert: findColorInvert($turquoise);
// $cyan-invert: findColorInvert($cyan);
// $blue-invert: findColorInvert($blue);
// $purple-invert: findColorInvert($purple);
// $red-invert: findColorInvert($red);
// $primary-invert: findColorInvert($primary);
// $primary-light: findLightColor($primary);
// $primary-dark: findDarkColor($primary);
// $info-invert: findColorInvert($info);
// $info-light: findLightColor($info);
// $info-dark: findDarkColor($info);
// $success-invert: findColorInvert($success);
// $success-light: findLightColor($success);
// $success-dark: findDarkColor($success);
// $warning-invert: findColorInvert($warning);
// $warning-light: findLightColor($warning);
// $warning-dark: findDarkColor($warning);
// $danger-invert: findColorInvert($danger);
// $danger-light: findLightColor($danger);
// $danger-dark: findDarkColor($danger);
// $light-invert: findColorInvert($light);
// $dark-invert: findColorInvert($dark);
// General colors
// $scheme-main: $white;
// $scheme-main-bis: $white-bis;
// $scheme-main-ter: $white-ter;
// $scheme-invert: $black;
// $scheme-invert-bis: $black-bis;
// $scheme-invert-ter: $black-ter;
// $background: $white-ter;
$border: $grey-light;
$border-hover: $grey;
// $border-light: $grey-lightest;
// $border-light-hover: $grey-light;
// Text colors
// $text: $grey-dark;
// $text-invert: findColorInvert($text);
// $text-light: $grey;
// $text-strong: $grey-darker;
// Code colors
// $code: $red;
// $code-background: $background;
// $pre: $text;
// $pre-background: $background;
// Link colors
// $link: $blue;
// $link-invert: findColorInvert($link);
// $link-light: findLightColor($link);
// $link-dark: findDarkColor($link);
// $link-visited: $purple;
// $link-hover: $grey-darker;
// $link-hover-border: $grey-light;
// $link-focus: $grey-darker;
// $link-focus-border: $blue;
// $link-active: $grey-darker;
// $link-active-border: $grey-dark;
// Typography
// $family-primary: $family-sans-serif;
// $family-secondary: $family-sans-serif;
// $family-code: $family-monospace;
// $size-small: $size-7;
// $size-normal: $size-6;
// $size-medium: $size-5;
// $size-large: $size-4;
// Lists and maps
// $custom-colors: null;
// $custom-shades: null;
// $colors: mergeColorMaps(("white": ($white, $black), "black": ($black, $white), "light": ($light, $light-invert), "dark": ($dark, $dark-invert), "primary": ($primary, $primary-invert, $primary-light, $primary-dark), "link": ($link, $link-invert, $link-light, $link-dark), "info": ($info, $info-invert, $info-light, $info-dark), "success": ($success, $success-invert, $success-light, $success-dark), "warning": ($warning, $warning-invert, $warning-light, $warning-dark), "danger": ($danger, $danger-invert, $danger-light, $danger-dark)), $custom-colors);
// $shades: mergeColorMaps(("black-bis": $black-bis, "black-ter": $black-ter, "grey-darker": $grey-darker, "grey-dark": $grey-dark, "grey": $grey, "grey-light": $grey-light, "grey-lighter": $grey-lighter, "white-ter": $white-ter, "white-bis": $white-bis), $custom-shades);
// $sizes: $size-1 $size-2 $size-3 $size-4 $size-5 $size-6 $size-7;
@import "~bulma/sass/base/minireset";
@import "~bulma/sass/base/generic";
@import "~bulma/sass/base/helpers";
@import "~bulma/sass/elements/box.sass";
@import "~bulma/sass/elements/button.sass";
@import "~bulma/sass/elements/container.sass";
@import "~bulma/sass/elements/content.sass";
@import "~bulma/sass/elements/icon.sass";
@import "~bulma/sass/elements/image.sass";
@import "~bulma/sass/elements/notification.sass";
@import "~bulma/sass/elements/progress.sass";
@import "~bulma/sass/elements/table.sass";
@import "~bulma/sass/elements/tag.sass";
@import "~bulma/sass/elements/title.sass";
@import "~bulma/sass/elements/other.sass";
// $input-color: $text-strong;
// $input-background-color: $scheme-main;
// $input-border-color: $border;
// $input-height: $control-height;
// $input-shadow: inset 0 0.0625em 0.125em rgba($scheme-invert, 0.05);
// $input-placeholder-color: rgba($input-color, 0.3);
// $input-hover-color: $text-strong;
// $input-hover-border-color: $border-hover;
// $input-focus-color: $text-strong;
// $input-focus-border-color: $link;
// $input-focus-box-shadow-size: 0 0 0 0.125em;
// $input-focus-box-shadow-color: rgba($link, 0.25);
// $input-disabled-color: $text-light;
// $input-disabled-background-color: $background;
// $input-disabled-border-color: $background;
// $input-disabled-placeholder-color: rgba($input-disabled-color, 0.3);
// $input-arrow: $link;
// $input-icon-color: $border;
// $input-icon-active-color: $text;
// $input-radius: $radius;
@import "~bulma/sass/form/shared.sass";
// $textarea-padding: $control-padding-horizontal;
// $textarea-max-height: 40em;
// $textarea-min-height: 8em;
@import "~bulma/sass/form/input-textarea.sass";
@import "~bulma/sass/form/checkbox-radio.sass";
@import "~bulma/sass/form/select.sass";
@import "~bulma/sass/form/file.sass";
@import "~bulma/sass/form/tools.sass";
// @import "~bulma/sass/components/breadcrumb.sass";
// @import "~bulma/sass/components/card.sass";
// @import "~bulma/sass/components/dropdown.sass";
// @import "~bulma/sass/components/level.sass";
// @import "~bulma/sass/components/list.sass";
// @import "~bulma/sass/components/media.sass";
// @import "~bulma/sass/components/menu.sass";
// @import "~bulma/sass/components/message.sass";
// @import "~bulma/sass/components/modal.sass";
// @import "~bulma/sass/components/navbar.sass";
// @import "~bulma/sass/components/pagination.sass";
// @import "~bulma/sass/components/panel.sass";
@import "~bulma/sass/components/tabs.sass";
@import "~bulma/sass/grid/columns.sass";
@import "~bulma/sass/grid/tiles.sass";
// @import "~bulma/sass/layout/hero.sass";
// @import "~bulma/sass/layout/section.sass";
// @import "~bulma/sass/layout/footer.sass";

View File

@ -152,7 +152,7 @@ $swal2-confirm-button-font-size: 1.0625em;
// CANCEL BUTTON // CANCEL BUTTON
$swal2-cancel-button-border: 0; $swal2-cancel-button-border: 0;
$swal2-cancel-button-border-radius: .25em; $swal2-cancel-button-border-radius: .25em;
$swal2-cancel-button-background-color: #aaa; $swal2-cancel-button-background-color: $background-floating;
$swal2-cancel-button-color: $swal2-white; $swal2-cancel-button-color: $swal2-white;
$swal2-cancel-button-font-size: 1.0625em; $swal2-cancel-button-font-size: 1.0625em;

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="about" @click.stop.prevent="toggle"> <div class="about" @click="toggle">
<div class="window"> <div class="window">
<div class="loading" v-if="loading"> <div class="loading" v-if="loading">
<div class="logo"> <div class="logo">
@ -147,7 +147,7 @@
if (this.about === '') { if (this.about === '') {
this.loading = true this.loading = true
this.$http this.$http
.get<string>('https://raw.githubusercontent.com/nurdism/neko/master/README.md') .get<string>('https://raw.githubusercontent.com/nurdism/neko/master/client/ABOUT.md')
.then(res => { .then(res => {
return this.$http.post('https://api.github.com/markdown', { return this.$http.post('https://api.github.com/markdown', {
text: res.data, text: res.data,
@ -163,8 +163,10 @@
} }
} }
toggle() { toggle(event: { target?: HTMLElement }) {
if (event.target && event.target.classList.contains('about')) {
this.$accessor.client.toggleAbout() this.$accessor.client.toggleAbout()
} }
} }
}
</script> </script>

View File

@ -3,7 +3,7 @@
<ul class="chat-history" ref="history" @click="onClick"> <ul class="chat-history" ref="history" @click="onClick">
<template v-for="(message, index) in history"> <template v-for="(message, index) in history">
<li :key="index" class="message" v-if="message.type === 'text'"> <li :key="index" class="message" v-if="message.type === 'text'">
<div class="author"> <div class="author" @contextmenu.stop.prevent="onContext($event, { member: member(message.id) })">
<img :src="`https://api.adorable.io/avatars/40/${member(message.id).username}.png`" /> <img :src="`https://api.adorable.io/avatars/40/${member(message.id).username}.png`" />
</div> </div>
<div class="content"> <div class="content">
@ -19,22 +19,24 @@
<li :key="index" class="event" v-if="message.type === 'event'"> <li :key="index" class="event" v-if="message.type === 'event'">
<span <span
v-tooltip="{ v-tooltip="{
content: `${member(message.id).username} ${message.content}`, content: `${timestamp(message.created)}, ${member(message.id).username} ${message.content}`,
placement: 'left', placement: 'left',
offset: 3, offset: 3,
boundariesElement: 'body', boundariesElement: 'body',
}" }"
> >
<strong>{{ member(message.id).username }}</strong> <strong v-if="message.id === id">You</strong>
<strong v-else>{{ member(message.id).username }}</strong>
{{ message.content }} {{ message.content }}
</span> </span>
</li> </li>
</template> </template>
</ul> </ul>
<div class="chat-send"> <neko-context ref="context" />
<div v-if="!muted" class="chat-send">
<div class="accent" /> <div class="accent" />
<div class="text-container"> <div class="text-container">
<textarea placeholder="Send a message" @keydown="onKeyDown" v-model="content" /> <textarea ref="chat" placeholder="Send a message" @keydown="onKeyDown" v-model="content" />
</div> </div>
</div> </div>
</div> </div>
@ -112,6 +114,8 @@
line-height: 22px; line-height: 22px;
.content-head { .content-head {
cursor: default;
span { span {
color: $text-normal; color: $text-normal;
font-weight: 500; font-weight: 500;
@ -213,6 +217,7 @@
display: flex; display: flex;
height: 15px; height: 15px;
color: $text-muted; color: $text-muted;
cursor: default;
span { span {
white-space: nowrap; white-space: nowrap;
@ -305,6 +310,7 @@
import { formatRelative } from 'date-fns' import { formatRelative } from 'date-fns'
import Markdown from './markdown' import Markdown from './markdown'
import Content from './context.vue'
const length = 512 // max length of message const length = 512 // max length of message
@ -312,10 +318,12 @@
name: 'neko-chat', name: 'neko-chat',
components: { components: {
'neko-markdown': Markdown, 'neko-markdown': Markdown,
'neko-context': Content,
}, },
}) })
export default class extends Vue { export default class extends Vue {
@Ref('history') readonly _history!: HTMLElement @Ref('history') readonly _history!: HTMLElement
@Ref('context') readonly _context!: any
_content = '' _content = ''
@ -323,6 +331,10 @@
return this.$accessor.user.id return this.$accessor.user.id
} }
get muted() {
return this.$accessor.user.muted
}
get history() { get history() {
return this.$accessor.chat.history return this.$accessor.chat.history
} }
@ -346,6 +358,13 @@
}) })
} }
@Watch('muted')
onMutedChange(muted: boolean) {
if (muted) {
this._content = ''
}
}
mounted() { mounted() {
this.$nextTick(() => { this.$nextTick(() => {
this._history.scrollTop = this._history.scrollHeight this._history.scrollTop = this._history.scrollHeight
@ -360,6 +379,10 @@
return formatRelative(time, new Date()) return formatRelative(time, new Date())
} }
onContext(event: MouseEvent, data: any) {
this._context.open(event, data)
}
onClick(event: { target?: HTMLElement; preventDefault(): void }) { onClick(event: { target?: HTMLElement; preventDefault(): void }) {
const { target } = event const { target } = event
if (!target) { if (!target) {
@ -382,7 +405,7 @@
} }
onKeyDown(event: KeyboardEvent) { onKeyDown(event: KeyboardEvent) {
if (typeof this._content === 'undefined') { if (typeof this._content === 'undefined' || this.muted) {
return return
} }

View File

@ -6,7 +6,7 @@
<span><b>n</b>.eko</span> <span><b>n</b>.eko</span>
</div> </div>
<form class="message" v-if="!connecting" @submit.stop.prevent="connect"> <form class="message" v-if="!connecting" @submit.stop.prevent="connect">
<span>Please enter the password:</span> <span>Please Login</span>
<input type="text" placeholder="Username" v-model="username" /> <input type="text" placeholder="Username" v-model="username" />
<input type="password" placeholder="Password" v-model="password" /> <input type="password" placeholder="Password" v-model="password" />
<button type="submit" @click.stop.prevent="connect"> <button type="submit" @click.stop.prevent="connect">
@ -67,9 +67,10 @@
flex-direction: column; flex-direction: column;
span { span {
display: block;
text-align: center; text-align: center;
text-transform: uppercase; text-transform: uppercase;
margin: 5px 0; line-height: 30px;
} }
input { input {
@ -145,7 +146,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Ref, Watch, Vue } from 'vue-property-decorator' import { Component, Ref, Watch, Vue } from 'vue-property-decorator'
import { get, set } from '~/utils/localstorage'
@Component({ name: 'neko-connect' }) @Component({ name: 'neko-connect' })
export default class extends Vue { export default class extends Vue {
private username = '' private username = ''
@ -156,8 +157,7 @@
} }
connect() { connect() {
const { username, password } = this this.$accessor.connect({ username: this.username, password: this.password })
this.$accessor.connect({ username, password })
} }
} }
</script> </script>

View File

@ -0,0 +1,244 @@
<template>
<vue-context class="context" ref="context">
<template slot-scope="child" v-if="child.data">
<li class="header">
<div class="user">
<img :src="`https://api.adorable.io/avatars/25/${child.data.member.username}.png`" />
<strong>{{ child.data.member.username }}</strong>
</div>
</li>
<li class="seperator" />
<li>
<span @click="ignore(child.data.member)" v-if="!child.data.member.ignored">Ignore</span>
<span @click="unignore(child.data.member)" v-else>Unignore</span>
</li>
<template v-if="admin">
<li>
<span @click="mute(child.data.member)" v-if="!child.data.member.muted">Mute</span>
<span @click="unmute(child.data.member)" v-else>Unmute</span>
</li>
<li v-if="child.data.member.id === host">
<span @click="adminRelease(child.data.member)">Force Release Controls</span>
</li>
<li v-if="child.data.member.id === host">
<span @click="adminControl(child.data.member)">Force Take Controls</span>
</li>
<li>
<span v-if="child.data.member.id !== host" @click="adminGive(child.data.member)">Give Controls</span>
</li>
</template>
<template v-else>
<li v-if="hosting">
<span @click="give(child.data.member)">Give Controls</span>
</li>
</template>
<template v-if="admin">
<li class="seperator" />
<li>
<span @click="kick(child.data.member)" style="color: #f04747">Kick</span>
</li>
<li>
<span @click="ban(child.data.member)" style="color: #f04747">Ban IP</span>
</li>
</template>
</template>
</vue-context>
</template>
<style lang="scss" scoped>
.context {
background-color: $background-floating;
background-clip: padding-box;
border-radius: 0.25rem;
display: block;
margin: 0;
padding: 5px;
min-width: 150px;
z-index: 1500;
position: fixed;
list-style: none;
box-sizing: border-box;
max-height: calc(100% - 50px);
overflow-y: auto;
color: $interactive-normal;
user-select: none;
box-shadow: $elevation-high;
> li {
margin: 0;
position: relative;
align-content: center;
&.header {
.user {
display: flex;
flex-direction: row;
align-content: center;
padding: 5px 0;
img {
width: 25px;
height: 25px;
border-radius: 50%;
margin-right: 5px;
}
strong {
line-height: 25px;
font-weight: 700;
max-width: 200px;
text-overflow: ellipsis;
}
}
}
&.seperator {
height: 1px;
background: $background-secondary;
margin: 3px 0;
}
> span {
cursor: pointer;
display: block;
padding: 5px;
font-weight: 400;
text-decoration: none;
white-space: nowrap;
background-color: transparent;
border-radius: 3px;
&:hover,
&:focus {
text-decoration: none;
background-color: $background-modifier-hover;
color: $interactive-hover;
}
&:focus {
outline: 0;
}
}
}
&:focus {
outline: 0;
}
}
</style>
<script lang="ts">
import { Component, Ref, Watch, Vue } from 'vue-property-decorator'
import { Member } from '~/neko/types'
// @ts-ignore
import { VueContext } from 'vue-context'
@Component({
name: 'neko-context',
components: {
'vue-context': VueContext,
},
})
export default class extends Vue {
@Ref('context') readonly context!: any
get admin() {
return this.$accessor.user.admin
}
get hosting() {
return this.$accessor.remote.hosting
}
get host() {
return this.$accessor.remote.id
}
open(event: MouseEvent, data: any) {
this.context.open(event, data)
}
kick(member: Member) {
this.$swal({
title: `Kick ${member.username}?`,
text: `Are you sure you want to kick ${member.username}?`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes',
}).then(({ value }) => {
if (value) {
this.$accessor.user.kick(member)
}
})
}
ban(member: Member) {
this.$swal({
title: `Ban ${member.username}?`,
text: `Are you sure you want to ban ${member.username}? You will need to restart the server to undo this.`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes',
}).then(({ value }) => {
if (value) {
this.$accessor.user.ban(member)
}
})
}
mute(member: Member) {
this.$swal({
title: `Mute ${member.username}?`,
text: `Are you sure you want to mute ${member.username}?`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes',
}).then(({ value }) => {
if (value) {
this.$accessor.user.mute(member)
}
})
}
unmute(member: Member) {
this.$swal({
title: `Unmute ${member.username}?`,
text: `Are you sure you want to unmute ${member.username}?`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes',
}).then(({ value }) => {
if (value) {
this.$accessor.user.unmute(member)
}
})
}
adminRelease(member: Member) {
this.$accessor.remote.adminRelease()
}
adminControl(member: Member) {
this.$accessor.remote.adminControl()
}
adminGive(member: Member) {
this.$accessor.remote.adminGive(member)
}
give(member: Member) {
this.$accessor.remote.give(member)
}
ignore(member: Member) {
this.$accessor.user.setIgnored({ id: member.id, ignored: true })
}
unignore(member: Member) {
this.$accessor.user.setIgnored({ id: member.id, ignored: false })
}
}
</script>

View File

@ -0,0 +1,107 @@
<template>
<div ref="emote" @click.stop.prevent="run" class="emote">
<i :class="classes"></i>
<i :class="classes"></i>
<i :class="classes"></i>
<i :class="classes"></i>
<i :class="classes"></i>
<i :class="classes"></i>
<i :class="classes"></i>
</div>
</template>
<style lang="scss" scoped>
.emote {
width: 150px;
height: 30px;
position: absolute;
bottom: 0;
right: 0;
i {
position: absolute;
width: 30px;
height: 30px;
color: #fff;
font-size: 30px;
line-height: 30px;
text-align: center;
&.heart {
color: rgb(204, 72, 72);
}
&.poo {
color: rgb(112, 89, 58);
}
&.grin {
color: rgb(228, 194, 84);
}
&.dizzy {
color: rgb(199, 199, 199);
}
}
}
</style>
<script lang="ts">
import { Component, Ref, Vue, Prop } from 'vue-property-decorator'
@Component({ name: 'neko-emote' })
export default class extends Vue {
@Prop({
required: true,
})
id!: string
@Ref('emote') container!: HTMLElement
get emote() {
return this.$accessor.chat.emotes[this.id]
}
private classes: string[] = []
mounted() {
const range = 90
let count = 0
let finish: Array<Promise<any>> = []
const emotes: any = {
heart: 'fa-heart',
poo: 'fa-poo',
grin: 'fa-grin-tears',
ghost: 'fa-ghost',
}
this.classes = ['fas', emotes[this.emote.type] || 'fa-heart', this.emote.type]
for (let child of this.container.children) {
const ele = child as HTMLElement
ele.style['left'] = `${count % 2 ? this.$anime.random(0, range) : this.$anime.random(-range, 0)}px`
ele.style['opacity'] = `0`
const animation = this.$anime({
targets: child,
keyframes: [
{ left: count % 2 ? this.$anime.random(0, range) : this.$anime.random(-range, 0), opacity: 1 },
{ left: count % 2 ? this.$anime.random(-range, 0) : this.$anime.random(0, range), opacity: 0.5 },
{ left: count % 2 ? this.$anime.random(0, range) : this.$anime.random(-range, 0), opacity: 0 },
],
elasticity: 600,
rotate: this.$anime.random(-35, 35),
top: this.$anime.random(-100, -250),
duration: this.$anime.random(1000, 2000),
easing: 'easeInOutQuad',
})
count++
finish.push(animation.finished)
}
Promise.all(finish).then(() => {
this.$emit('done', this.id)
this.$accessor.chat.delEmote(this.id)
})
}
}
</script>

View File

@ -1,16 +1,16 @@
<template> <template>
<div class="emoji"> <div class="emotes">
<ul> <ul v-if="!muted">
<li><i @click.stop.prevent="click('heart')" class="fas fa-heart"></i></li> <li><i @click.stop.prevent="click('heart')" class="fas fa-heart"></i></li>
<li><i @click.stop.prevent="click('poo')" class="fas fa-poo"></i></li> <li><i @click.stop.prevent="click('poo')" class="fas fa-poo"></i></li>
<li><i @click.stop.prevent="click('grin')" class="fas fa-grin-tears"></i></li> <li><i @click.stop.prevent="click('grin')" class="fas fa-grin-tears"></i></li>
<li><i @click.stop.prevent="click('dizzy')" class="fas fa-dizzy"></i></li> <li><i @click.stop.prevent="click('ghost')" class="fas fa-ghost"></i></li>
</ul> </ul>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.emoji { .emotes {
ul { ul {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -33,16 +33,15 @@
import { Vue, Component } from 'vue-property-decorator' import { Vue, Component } from 'vue-property-decorator'
@Component({ @Component({
name: 'neko-emoji', name: 'neko-emotes',
}) })
export default class extends Vue { export default class extends Vue {
click(emoji: string) { get muted() {
this.$swal({ return this.$accessor.user.muted
title: 'Error!', }
text: 'This feature is not available yet',
icon: 'error', click(emote: string) {
confirmButtonText: 'Cool', this.$accessor.chat.sendEmote(emote)
})
} }
} }
</script> </script>

View File

@ -0,0 +1,116 @@
<template>
<div class="header">
<div class="neko">
<img src="@/assets/logo.svg" alt="n.eko" />
<span><b>n</b>.eko</span>
</div>
<ul class="menu">
<li>
<i
:class="[{ disabled: !admin }, { 'fa-lock-open': !locked }, { 'fa-lock': locked }, 'fas', 'lock']"
@click="toggleLock"
/>
</li>
<li>
<i class="fas fa-bars toggle" @click="toggleMenu" />
</li>
</ul>
</div>
</template>
<style lang="scss" scoped>
.header {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
.neko {
flex: 1;
display: flex;
justify-content: flex-start;
align-items: center;
width: 150px;
margin-left: 20px;
img {
display: block;
float: left;
height: 30px;
margin-right: 10px;
}
span {
font-size: 30px;
line-height: 30px;
b {
font-weight: 900;
}
}
}
.menu {
justify-self: flex-end;
margin-right: 10px;
white-space: nowrap;
li {
display: inline-block;
margin-right: 10px;
i {
display: block;
width: 30px;
height: 30px;
text-align: center;
line-height: 32px;
border-radius: 3px;
cursor: pointer;
}
.disabled {
cursor: default;
opacity: 0.8;
}
.fa-lock {
color: rgba($color: $style-error, $alpha: 0.5);
}
.toggle {
background: $background-primary;
}
}
}
}
</style>
<script lang="ts">
import { Component, Ref, Watch, Vue } from 'vue-property-decorator'
@Component({ name: 'neko-settings' })
export default class extends Vue {
get admin() {
return this.$accessor.user.admin
}
get locked() {
return this.$accessor.locked
}
toggleMenu() {
this.$accessor.client.toggleSide()
}
toggleLock() {
if (this.admin) {
if (this.locked) {
this.$accessor.remote.unlock()
} else {
this.$accessor.remote.lock()
}
}
}
}
</script>

View File

@ -11,45 +11,19 @@
<li <li
v-if="member.id !== id && member.connected" v-if="member.id !== id && member.connected"
:key="index" :key="index"
v-tooltip="{ content: member.username, placement: 'top', offset: 5, boundariesElement: 'body' }" v-tooltip="{ content: member.username, placement: 'bottom', offset: -15, boundariesElement: 'body' }"
> >
<div :class="[{ host: member.id === host, admin: member.admin }, 'member']"> <div :class="[{ host: member.id === host, admin: member.admin }, 'member']">
<img <img
:src="`https://api.adorable.io/avatars/50/${member.username}.png`" :src="`https://api.adorable.io/avatars/50/${member.username}.png`"
@contextmenu="context($event, { member, index })" @contextmenu.stop.prevent="onContext($event, { member })"
/> />
</div> </div>
</li> </li>
</template> </template>
</ul> </ul>
</div> </div>
<vue-context class="context" ref="menu"> <neko-context ref="context" />
<template slot-scope="child" v-if="child.data && admin">
<li>
<strong>{{ child.data.member.username }}</strong>
</li>
<li class="seperator" />
<li>
<span @click="mute(child.data.member)" v-if="!child.data.member.muted">Mute</span>
<span @click="unmute(child.data.member)" v-else>Unmute</span>
</li>
<template v-if="child.data.member.id === host">
<li>
<span @click="release(child.data.member)">Release Controls</span>
</li>
<li>
<span @click="control(child.data.member)">Take Controls</span>
</li>
</template>
<li class="seperator" />
<li>
<span @click="kick(child.data.member)" style="color: #f04747">Kick</span>
</li>
<li>
<span @click="ban(child.data.member)" style="color: #f04747">Ban</span>
</li>
</template>
</vue-context>
</div> </div>
</template> </template>
@ -179,95 +153,28 @@
} }
} }
} }
.context {
background-color: $background-floating;
background-clip: padding-box;
border-radius: 0.25rem;
display: block;
margin: 0;
padding: 5px;
min-width: 150px;
z-index: 1500;
position: fixed;
list-style: none;
box-sizing: border-box;
max-height: calc(100% - 50px);
overflow-y: auto;
color: $interactive-normal;
user-select: none;
box-shadow: $elevation-high;
> li {
margin: 0;
position: relative;
&.seperator {
height: 1px;
background: $background-secondary;
margin: 3px 0;
}
> strong {
display: block;
padding: 8px 5px;
font-weight: 700;
}
> span {
cursor: pointer;
display: block;
padding: 5px;
font-weight: 400;
text-decoration: none;
white-space: nowrap;
background-color: transparent;
border-radius: 3px;
&:hover,
&:focus {
text-decoration: none;
background-color: $background-modifier-hover;
color: $interactive-hover;
}
&:focus {
outline: 0;
}
}
}
&:focus {
outline: 0;
}
}
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import { Component, Ref, Watch, Vue } from 'vue-property-decorator' import { Component, Ref, Watch, Vue } from 'vue-property-decorator'
import { Member } from '~/client/types' import { Member } from '~/neko/types'
// @ts-ignore import Content from './context.vue'
import { VueContext } from 'vue-context'
@Component({ @Component({
name: 'neko-members', name: 'neko-members',
components: { components: {
'vue-context': VueContext, 'neko-context': Content,
}, },
}) })
export default class extends Vue { export default class extends Vue {
@Ref('menu') readonly menu!: any @Ref('context') readonly _context!: any
get id() { get id() {
return this.$accessor.user.id return this.$accessor.user.id
} }
get admin() {
return this.$accessor.user.admin
}
get host() { get host() {
return this.$accessor.remote.id return this.$accessor.remote.id
} }
@ -280,75 +187,8 @@
return this.$accessor.user.members return this.$accessor.user.members
} }
context(event: MouseEvent, data: any) { onContext(event: MouseEvent, data: any) {
if (this.admin) { this._context.open(event, data)
event.preventDefault()
this.menu.open(event, data)
}
}
kick(member: Member) {
this.$swal({
title: `Kick ${member.username}?`,
text: `Are you sure you want to kick ${member.username}?`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes',
}).then(({ value }) => {
if (value) {
this.$accessor.user.kick(member)
}
})
}
ban(member: Member) {
this.$swal({
title: `Ban ${member.username}?`,
text: `Are you sure you want to ban ${member.username}? You will need to restart the server to undo this.`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes',
}).then(({ value }) => {
if (value) {
this.$accessor.user.ban(member)
}
})
}
mute(member: Member) {
this.$swal({
title: `Mute ${member.username}?`,
text: `Are you sure you want to mute ${member.username}?`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes',
}).then(({ value }) => {
if (value) {
this.$accessor.user.mute(member)
}
})
}
unmute(member: Member) {
this.$swal({
title: `Unmute ${member.username}?`,
text: `Are you sure you want to unmute ${member.username}?`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes',
}).then(({ value }) => {
if (value) {
this.$accessor.user.unmute(member)
}
})
}
release(member: Member) {
this.$accessor.remote.adminRelease()
}
control(member: Member) {
this.$accessor.remote.adminControl()
} }
} }
</script> </script>

View File

@ -1,12 +1,216 @@
<template> <template>
<div></div> <div class="settings">
<ul>
<li>
<span>Scroll Sensitivity</span>
<label class="slider">
<input type="range" min="5" max="100" v-model="scroll" />
</label>
</li>
<li>
<span>Invert Scroll</span>
<label class="switch">
<input type="checkbox" v-model="scroll_invert" />
<span />
</label>
</li>
<li>
<span>Autoplay Video</span>
<label class="switch">
<input type="checkbox" v-model="autoplay" />
<span />
</label>
</li>
<li>
<span>Ignore Emotes</span>
<label class="switch">
<input type="checkbox" v-model="ignore_emotes" />
<span />
</label>
</li>
<li>
<span>Play Chat Sound</span>
<label class="switch">
<input type="checkbox" v-model="chat_sound" />
<span />
</label>
</li>
</ul>
</div>
</template> </template>
<style lang="scss" scoped></style> <style lang="scss" scoped>
.settings {
flex: 1;
display: flex;
ul {
flex: 1;
display: flex;
flex-direction: column;
padding: 5px 20px;
li {
display: flex;
flex-direction: row;
align-content: center;
justify-content: center;
border-bottom: 1px solid $background-secondary;
padding: 5px 0;
white-space: nowrap;
&:last-child {
border-bottom: none;
}
span {
margin-right: auto;
height: 24px;
line-height: 24px;
}
.switch {
justify-self: flex-end;
position: relative;
width: 42px;
height: 24px;
input {
opacity: 0;
width: 0;
height: 0;
}
span {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $background-tertiary;
transition: 0.4s;
border-radius: 34px;
&:before {
position: absolute;
content: '';
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
}
}
input[type='checkbox'] {
&:checked + span {
background-color: $style-primary;
}
&:checked + span:before {
transform: translateX(18px);
}
}
.slider {
white-space: nowrap;
max-width: 120px;
input[type='range'] {
display: inline-block;
background: transparent;
appearance: none;
height: 24px;
max-width: 120px;
&::-moz-range-thumb {
height: 12px;
width: 12px;
border-radius: 12px;
background: $interactive-active;
cursor: pointer;
}
&::-moz-range-track {
width: 100%;
height: 4px;
cursor: pointer;
background: $style-primary;
border-radius: 2px;
}
&::-webkit-slider-thumb {
appearance: none;
height: 12px;
width: 12px;
border-radius: 12px;
background: $interactive-active;
cursor: pointer;
margin-top: -4px;
}
&::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: $style-primary;
border-radius: 2px;
}
}
}
}
}
}
</style>
<script lang="ts"> <script lang="ts">
import { Component, Ref, Watch, Vue } from 'vue-property-decorator' import { Component, Ref, Watch, Vue } from 'vue-property-decorator'
@Component({ name: 'neko-settings' }) @Component({ name: 'neko-settings' })
export default class extends Vue {} export default class extends Vue {
get scroll() {
return this.$accessor.settings.scroll.toString()
}
set scroll(value: string) {
this.$accessor.settings.setScroll(parseInt(value))
}
get scroll_invert() {
return this.$accessor.settings.scroll_invert
}
set scroll_invert(value: boolean) {
this.$accessor.settings.setInvert(value)
}
get autoplay() {
return this.$accessor.settings.autoplay
}
set autoplay(value: boolean) {
this.$accessor.settings.setAutoplay(value)
}
get ignore_emotes() {
return this.$accessor.settings.ignore_emotes
}
set ignore_emotes(value: boolean) {
this.$accessor.settings.setIgnore(value)
}
get chat_sound() {
return this.$accessor.settings.chat_sound
}
set chat_sound(value: boolean) {
this.$accessor.settings.setSound(value)
}
}
</script> </script>

View File

@ -68,6 +68,7 @@
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
overflow: auto; overflow: auto;
padding-top: 5px;
} }
} }
</style> </style>

View File

@ -3,6 +3,11 @@
<div ref="player" class="player"> <div ref="player" class="player">
<div ref="container" class="player-container"> <div ref="container" class="player-container">
<video ref="video" /> <video ref="video" />
<div class="emotes">
<template v-for="(emote, index) in emotes">
<neko-emote :id="index" :key="index" />
</template>
</div>
<div <div
ref="overlay" ref="overlay"
class="overlay" class="overlay"
@ -73,12 +78,17 @@
} }
} }
.player-overlay { .player-overlay,
.emotes {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden;
}
.player-overlay {
background: rgba($color: #000, $alpha: 0.2); background: rgba($color: #000, $alpha: 0.2);
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -118,7 +128,14 @@
import { Component, Ref, Watch, Vue } from 'vue-property-decorator' import { Component, Ref, Watch, Vue } from 'vue-property-decorator'
import ResizeObserver from 'resize-observer-polyfill' import ResizeObserver from 'resize-observer-polyfill'
@Component({ name: 'neko-video' }) import Emote from './emote.vue'
@Component({
name: 'neko-video',
components: {
'neko-emote': Emote,
},
})
export default class extends Vue { export default class extends Vue {
@Ref('component') readonly _component!: HTMLElement @Ref('component') readonly _component!: HTMLElement
@Ref('container') readonly _container!: HTMLElement @Ref('container') readonly _container!: HTMLElement
@ -163,6 +180,22 @@
return this.$accessor.video.playable return this.$accessor.video.playable
} }
get emotes() {
return this.$accessor.chat.emotes
}
get autoplay() {
return this.$accessor.settings.autoplay
}
get scroll() {
return this.$accessor.settings.scroll
}
get scroll_invert() {
return this.$accessor.settings.scroll_invert
}
@Watch('volume') @Watch('volume')
onVolumeChanged(volume: number) { onVolumeChanged(volume: number) {
if (this._video) { if (this._video) {
@ -189,11 +222,6 @@
// @ts-ignore // @ts-ignore
this._video.src = window.URL.createObjectURL(this.stream) // for older browsers this._video.src = window.URL.createObjectURL(this.stream) // for older browsers
} }
if (this._video.paused && this.playing) {
// TODO: auto play setting
this.play()
}
} }
@Watch('playing') @Watch('playing')
@ -220,6 +248,9 @@
this._video.addEventListener('canplaythrough', () => { this._video.addEventListener('canplaythrough', () => {
this.$accessor.video.setPlayable(true) this.$accessor.video.setPlayable(true)
if (this.autoplay) {
this.$accessor.video.play()
}
}) })
this._video.addEventListener('ended', () => { this._video.addEventListener('ended', () => {
@ -291,10 +322,19 @@
return return
} }
this.onMousePos(e) this.onMousePos(e)
this.$client.sendData('wheel', {
x: (e.deltaX * -1) / 10, let x = e.deltaX
y: (e.deltaY * -1) / 10, let y = e.deltaY
}) // TODO: Add user settings
if (this.scroll_invert) {
x = x * -1
y = y * -1
}
x = Math.min(Math.max(x, -this.scroll), this.scroll)
y = Math.min(Math.max(y, -this.scroll), this.scroll)
this.$client.sendData('wheel', { x, y })
} }
onMouseDown(e: MouseEvent) { onMouseDown(e: MouseEvent) {

View File

@ -7,6 +7,7 @@ import ToolTip from 'v-tooltip'
import Client from './plugins/neko' import Client from './plugins/neko'
import Axios from './plugins/axios' import Axios from './plugins/axios'
import Swal from './plugins/swal' import Swal from './plugins/swal'
import Anime from './plugins/anime'
import store from './store' import store from './store'
import app from './app.vue' import app from './app.vue'
@ -17,6 +18,7 @@ Vue.use(Notifications)
Vue.use(ToolTip) Vue.use(ToolTip)
Vue.use(Axios) Vue.use(Axios)
Vue.use(Swal) Vue.use(Swal)
Vue.use(Anime)
Vue.use(Client) Vue.use(Client)
new Vue({ new Vue({

View File

@ -50,15 +50,20 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
if (username === '') { if (username === '') {
throw new Error('Must add a username') // TODO: Better handleing throw new Error('Must add a username') // TODO: Better handleing
} }
this._username = username
this._username = username
this[EVENT.CONNECTING]()
try {
this._ws = new WebSocket(`${url}ws?password=${password}`) this._ws = new WebSocket(`${url}ws?password=${password}`)
this.emit('debug', `connecting to ${this._ws.url}`) this.emit('debug', `connecting to ${this._ws.url}`)
this._ws.onmessage = this.onMessage.bind(this) this._ws.onmessage = this.onMessage.bind(this)
this._ws.onerror = event => this.onError.bind(this) this._ws.onerror = event => this.onError.bind(this)
this._ws.onclose = event => this.onDisconnected.bind(this, new Error('websocket closed')) this._ws.onclose = event => this.onDisconnected.bind(this, new Error('websocket closed'))
this._timeout = setTimeout(this.onTimeout.bind(this), 5000) this._timeout = setTimeout(this.onTimeout.bind(this), 5000)
this[EVENT.CONNECTING]() } catch (err) {
this.onDisconnected(err)
}
} }
public sendData(event: 'wheel' | 'mousemove', data: { x: number; y: number }): void public sendData(event: 'wheel' | 'mousemove', data: { x: number; y: number }): void
@ -163,6 +168,9 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
case 'connected': case 'connected':
this.onConnected() this.onConnected()
break break
case 'failed':
this.onDisconnected(new Error('peer failed'))
break
case 'disconnected': case 'disconnected':
this.onDisconnected(new Error('peer disconnected')) this.onDisconnected(new Error('peer disconnected'))
break break

View File

@ -29,10 +29,11 @@ export const EVENT = {
RELEASE: 'control/release', RELEASE: 'control/release',
REQUEST: 'control/request', REQUEST: 'control/request',
REQUESTING: 'control/requesting', REQUESTING: 'control/requesting',
GIVE: 'control/give',
}, },
CHAT: { CHAT: {
MESSAGE: 'chat/message', MESSAGE: 'chat/message',
EMOJI: 'chat/emoji', EMOTE: 'chat/emote',
}, },
ADMIN: { ADMIN: {
BAN: 'admin/ban', BAN: 'admin/ban',
@ -43,6 +44,7 @@ export const EVENT = {
UNMUTE: 'admin/unmute', UNMUTE: 'admin/unmute',
CONTROL: 'admin/control', CONTROL: 'admin/control',
RELEASE: 'admin/release', RELEASE: 'admin/release',
GIVE: 'admin/give',
}, },
} as const } as const
@ -57,12 +59,17 @@ export type WebSocketEvents =
| ChatEvents | ChatEvents
| AdminEvents | AdminEvents
export type ControlEvents =
| typeof EVENT.CONTROL.LOCKED
| typeof EVENT.CONTROL.RELEASE
| typeof EVENT.CONTROL.REQUEST
| typeof EVENT.CONTROL.GIVE
export type SystemEvents = typeof EVENT.SYSTEM.DISCONNECT export type SystemEvents = typeof EVENT.SYSTEM.DISCONNECT
export type ControlEvents = typeof EVENT.CONTROL.LOCKED | typeof EVENT.CONTROL.RELEASE | typeof EVENT.CONTROL.REQUEST
export type IdentityEvents = typeof EVENT.IDENTITY.PROVIDE | typeof EVENT.IDENTITY.DETAILS export type IdentityEvents = typeof EVENT.IDENTITY.PROVIDE | typeof EVENT.IDENTITY.DETAILS
export type MemberEvents = typeof EVENT.MEMBER.LIST | typeof EVENT.MEMBER.CONNECTED | typeof EVENT.MEMBER.DISCONNECTED 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
export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOJI export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
export type AdminEvents = export type AdminEvents =
| typeof EVENT.ADMIN.BAN | typeof EVENT.ADMIN.BAN
| typeof EVENT.ADMIN.KICK | typeof EVENT.ADMIN.KICK
@ -72,3 +79,4 @@ export type AdminEvents =
| typeof EVENT.ADMIN.UNMUTE | typeof EVENT.ADMIN.UNMUTE
| typeof EVENT.ADMIN.CONTROL | typeof EVENT.ADMIN.CONTROL
| typeof EVENT.ADMIN.RELEASE | typeof EVENT.ADMIN.RELEASE
| typeof EVENT.ADMIN.GIVE

View File

@ -12,8 +12,9 @@ import {
MemberDisconnectPayload, MemberDisconnectPayload,
MemberPayload, MemberPayload,
ControlPayload, ControlPayload,
ControlTargetPayload,
ChatPayload, ChatPayload,
EmojiPayload, EmotePayload,
AdminPayload, AdminPayload,
AdminTargetPayload, AdminTargetPayload,
} from './messages' } from './messages'
@ -60,20 +61,27 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
duration: 5000, duration: 5000,
speed: 1000, speed: 1000,
}) })
this.$accessor.chat.newMessage({
id: this.id,
content: 'connected',
type: 'event',
created: new Date(),
})
} }
protected [EVENT.DISCONNECTED](reason?: Error) { protected [EVENT.DISCONNECTED](reason?: Error) {
this.$accessor.setConnected(false) this.$accessor.setConnected(false)
this.$accessor.remote.clearHost() this.$accessor.remote.clear()
this.$accessor.user.clearMembers() this.$accessor.user.clear()
this.$accessor.video.clear() this.$accessor.video.clear()
this.$accessor.chat.clear() this.$accessor.chat.clear()
this.$vue.$notify({ this.$vue.$notify({
group: 'neko', group: 'neko',
type: 'error', type: 'error',
title: `Disconnected`, title: `Disconnected:`,
text: reason ? reason.message : undefined, text: reason ? reason.message : undefined,
duration: 5000, duration: 5000,
speed: 1000, speed: 1000,
@ -98,7 +106,7 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
protected [EVENT.SYSTEM.DISCONNECT]({ message }: DisconnectPayload) { protected [EVENT.SYSTEM.DISCONNECT]({ message }: DisconnectPayload) {
this.onDisconnected(new Error(message)) this.onDisconnected(new Error(message))
this.$vue.$swal({ this.$vue.$swal({
title: 'Error!', title: 'Disconnected!',
text: message, text: message,
icon: 'error', icon: 'error',
confirmButtonText: 'ok', confirmButtonText: 'ok',
@ -123,7 +131,7 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
this.$accessor.user.addMember(member) this.$accessor.user.addMember(member)
if (member.id !== this.id) { if (member.id !== this.id) {
this.$accessor.chat.addMessage({ this.$accessor.chat.newMessage({
id: member.id, id: member.id,
content: 'connected', content: 'connected',
type: 'event', type: 'event',
@ -138,7 +146,7 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
return return
} }
this.$accessor.chat.addMessage({ this.$accessor.chat.newMessage({
id: member.id, id: member.id,
content: 'disconnected', content: 'disconnected',
type: 'event', type: 'event',
@ -166,18 +174,18 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
duration: 5000, duration: 5000,
speed: 1000, speed: 1000,
}) })
} else { }
this.$accessor.chat.addMessage({
this.$accessor.chat.newMessage({
id: member.id, id: member.id,
content: 'took the controls', content: 'took the controls',
type: 'event', type: 'event',
created: new Date(), created: new Date(),
}) })
} }
}
protected [EVENT.CONTROL.RELEASE]({ id }: ControlPayload) { protected [EVENT.CONTROL.RELEASE]({ id }: ControlPayload) {
this.$accessor.remote.clearHost() this.$accessor.remote.clear()
const member = this.member(id) const member = this.member(id)
if (!member) { if (!member) {
return return
@ -191,15 +199,15 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
duration: 5000, duration: 5000,
speed: 1000, speed: 1000,
}) })
} else { }
this.$accessor.chat.addMessage({
this.$accessor.chat.newMessage({
id: member.id, id: member.id,
content: 'released the controls', content: 'released the controls',
type: 'event', type: 'event',
created: new Date(), created: new Date(),
}) })
} }
}
protected [EVENT.CONTROL.REQUEST]({ id }: ControlPayload) { protected [EVENT.CONTROL.REQUEST]({ id }: ControlPayload) {
const member = this.member(id) const member = this.member(id)
@ -219,7 +227,7 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
protected [EVENT.CONTROL.REQUESTING]({ id }: ControlPayload) { protected [EVENT.CONTROL.REQUESTING]({ id }: ControlPayload) {
const member = this.member(id) const member = this.member(id)
if (!member) { if (!member || member.ignored) {
return return
} }
@ -232,11 +240,31 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
}) })
} }
protected [EVENT.CONTROL.GIVE]({ id, target }: ControlTargetPayload) {
const member = this.member(target)
if (!member) {
return
}
this.$accessor.remote.setHost(member)
this.$accessor.chat.newMessage({
id,
content: `gave the controls to ${member.id == this.id ? 'you' : member.username}`,
type: 'event',
created: new Date(),
})
}
///////////////////////////// /////////////////////////////
// Chat Events // Chat Events
///////////////////////////// /////////////////////////////
protected [EVENT.CHAT.MESSAGE]({ id, content }: ChatPayload) { protected [EVENT.CHAT.MESSAGE]({ id, content }: ChatPayload) {
this.$accessor.chat.addMessage({ const member = this.member(id)
if (!member || member.ignored) {
return
}
this.$accessor.chat.newMessage({
id, id,
content, content,
type: 'text', type: 'text',
@ -244,8 +272,13 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
}) })
} }
protected [EVENT.CHAT.EMOJI]({ id, emoji }: EmojiPayload) { protected [EVENT.CHAT.EMOTE]({ id, emote }: EmotePayload) {
// const member = this.member(id)
if (!member || member.ignored) {
return
}
this.$accessor.chat.newEmote({ type: emote })
} }
///////////////////////////// /////////////////////////////
@ -261,9 +294,9 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
return return
} }
this.$accessor.chat.addMessage({ this.$accessor.chat.newMessage({
id, id,
content: `banned ${member.username}`, content: `banned ${member.id == this.id ? 'you' : member.username}`,
type: 'event', type: 'event',
created: new Date(), created: new Date(),
}) })
@ -279,9 +312,9 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
return return
} }
this.$accessor.chat.addMessage({ this.$accessor.chat.newMessage({
id, id,
content: `kicked ${member.username}`, content: `kicked ${member.id == this.id ? 'you' : member.username}`,
type: 'event', type: 'event',
created: new Date(), created: new Date(),
}) })
@ -299,9 +332,9 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
return return
} }
this.$accessor.chat.addMessage({ this.$accessor.chat.newMessage({
id, id,
content: `muted ${member.username}`, content: `muted ${member.id == this.id ? 'you' : member.username}`,
type: 'event', type: 'event',
created: new Date(), created: new Date(),
}) })
@ -319,7 +352,7 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
return return
} }
this.$accessor.chat.addMessage({ this.$accessor.chat.newMessage({
id, id,
content: `unmuted ${member.username}`, content: `unmuted ${member.username}`,
type: 'event', type: 'event',
@ -328,7 +361,8 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
} }
protected [EVENT.ADMIN.LOCK]({ id }: AdminPayload) { protected [EVENT.ADMIN.LOCK]({ id }: AdminPayload) {
this.$accessor.chat.addMessage({ this.$accessor.setLocked(true)
this.$accessor.chat.newMessage({
id, id,
content: `locked the room`, content: `locked the room`,
type: 'event', type: 'event',
@ -337,7 +371,8 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
} }
protected [EVENT.ADMIN.UNLOCK]({ id }: AdminPayload) { protected [EVENT.ADMIN.UNLOCK]({ id }: AdminPayload) {
this.$accessor.chat.addMessage({ this.$accessor.setLocked(false)
this.$accessor.chat.newMessage({
id, id,
content: `unlocked the room`, content: `unlocked the room`,
type: 'event', type: 'event',
@ -349,7 +384,7 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
this.$accessor.remote.setHost(id) this.$accessor.remote.setHost(id)
if (!target) { if (!target) {
this.$accessor.chat.addMessage({ this.$accessor.chat.newMessage({
id, id,
content: `force took the controls`, content: `force took the controls`,
type: 'event', type: 'event',
@ -363,18 +398,18 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
return return
} }
this.$accessor.chat.addMessage({ this.$accessor.chat.newMessage({
id, id,
content: `took the controls from ${member.username}`, content: `took the controls from ${member.id == this.id ? 'you' : member.username}`,
type: 'event', type: 'event',
created: new Date(), created: new Date(),
}) })
} }
protected [EVENT.ADMIN.RELEASE]({ id, target }: AdminTargetPayload) { protected [EVENT.ADMIN.RELEASE]({ id, target }: AdminTargetPayload) {
this.$accessor.remote.clearHost() this.$accessor.remote.clear()
if (!target) { if (!target) {
this.$accessor.chat.addMessage({ this.$accessor.chat.newMessage({
id, id,
content: `force released the controls`, content: `force released the controls`,
type: 'event', type: 'event',
@ -388,9 +423,29 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
return return
} }
this.$accessor.chat.addMessage({ this.$accessor.chat.newMessage({
id, id,
content: `released the controls from ${member.username}`, content: `released the controls from ${member.id == this.id ? 'you' : member.username}`,
type: 'event',
created: new Date(),
})
}
protected [EVENT.ADMIN.GIVE]({ id, target }: AdminTargetPayload) {
if (!target) {
return
}
const member = this.member(target)
if (!member) {
return
}
this.$accessor.remote.setHost(member)
this.$accessor.chat.newMessage({
id,
content: `gave the controls to ${member.id == this.id ? 'you' : member.username}`,
type: 'event', type: 'event',
created: new Date(), created: new Date(),
}) })

View File

@ -1,4 +1,14 @@
import { WebSocketEvents, EVENT } from './events' import {
EVENT,
WebSocketEvents,
SystemEvents,
ControlEvents,
IdentityEvents,
MemberEvents,
SignalEvents,
ChatEvents,
AdminEvents,
} from './events'
import { Member } from './types' import { Member } from './types'
export type WebSocketMessages = export type WebSocketMessages =
@ -89,12 +99,17 @@ export interface MemberDisconnectPayload {
*/ */
// control/locked & control/release & control/request // control/locked & control/release & control/request
export interface ControlMessage extends WebSocketMessage, ControlPayload { export interface ControlMessage extends WebSocketMessage, ControlPayload {
event: typeof EVENT.CONTROL.LOCKED | typeof EVENT.CONTROL.RELEASE | typeof EVENT.CONTROL.REQUEST event: ControlEvents
} }
export interface ControlPayload { export interface ControlPayload {
id: string id: string
} }
export interface ControlTargetPayload {
id: string
target: string
}
/* /*
CHAT PAYLOADS CHAT PAYLOADS
*/ */
@ -112,24 +127,24 @@ export interface ChatPayload {
} }
// chat/emoji // chat/emoji
export interface ChatEmojiMessage extends WebSocketMessage, EmojiPayload { export interface ChatEmoteMessage extends WebSocketMessage, EmotePayload {
event: typeof EVENT.CHAT.EMOJI event: typeof EVENT.CHAT.EMOTE
} }
export interface EmojiPayload { export interface EmotePayload {
id: string id: string
emoji: string emote: string
} }
export interface EmojiSendPayload { export interface EmojiSendPayload {
emoji: string emote: string
} }
/* /*
ADMIN PAYLOADS ADMIN PAYLOADS
*/ */
export interface AdminMessage extends WebSocketMessage, AdminPayload { export interface AdminMessage extends WebSocketMessage, AdminPayload {
event: typeof EVENT.MESSAGE event: AdminEvents
} }
export interface AdminPayload { export interface AdminPayload {
@ -137,7 +152,7 @@ export interface AdminPayload {
} }
export interface AdminTargetMessage extends WebSocketMessage, AdminTargetPayload { export interface AdminTargetMessage extends WebSocketMessage, AdminTargetPayload {
event: typeof EVENT.CHAT.EMOJI event: AdminEvents
} }
export interface AdminTargetPayload { export interface AdminTargetPayload {

View File

@ -4,4 +4,5 @@ export interface Member {
admin: boolean admin: boolean
muted: boolean muted: boolean
connected?: boolean connected?: boolean
ignored?: boolean
} }

View File

@ -1,5 +1,5 @@
import { PluginObject } from 'vue' import { PluginObject } from 'vue'
import { NekoClient } from '~/client' import { NekoClient } from '~/neko'
declare global { declare global {
const $client: NekoClient const $client: NekoClient

View File

@ -1,27 +0,0 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import chat from '~/pages/chat.vue'
import about from '~/pages/about.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'chat',
component: chat,
},
{
path: '/about',
name: 'about',
component: about,
},
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes,
})
export default router

View File

@ -1,9 +1,18 @@
import { getterTree, mutationTree, actionTree } from 'typed-vuex' import { getterTree, mutationTree, actionTree } from 'typed-vuex'
import { EVENT } from '~/client/events' import { makeid } from '~/utils'
import { EVENT } from '~/neko/events'
import { accessor } from '~/store' import { accessor } from '~/store'
export const namespaced = true export const namespaced = true
interface Emote {
type: string
}
interface Emotes {
[id: string]: Emote
}
interface Message { interface Message {
id: string id: string
content: string content: string
@ -13,6 +22,7 @@ interface Message {
export const state = () => ({ export const state = () => ({
history: [] as Message[], history: [] as Message[],
emotes: {} as Emotes,
}) })
export const getters = getterTree(state, { export const getters = getterTree(state, {
@ -23,7 +33,24 @@ export const mutations = mutationTree(state, {
addMessage(state, message: Message) { addMessage(state, message: Message) {
state.history = state.history.concat([message]) state.history = state.history.concat([message])
}, },
addEmote(state, { id, emote }: { id: string; emote: Emote }) {
state.emotes = {
...state.emotes,
[id]: emote,
}
},
delEmote(state, id: string) {
const emotes = {
...state.emotes,
}
delete emotes[id]
state.emotes = emotes
},
clear(state) { clear(state) {
state.emotes = {}
state.history = [] state.history = []
}, },
}) })
@ -31,20 +58,34 @@ export const mutations = mutationTree(state, {
export const actions = actionTree( export const actions = actionTree(
{ state, getters, mutations }, { state, getters, mutations },
{ {
newEmote(store, emote: Emote) {
if (accessor.settings.ignore_emotes) {
return
}
const id = makeid(10)
accessor.chat.addEmote({ id, emote })
},
newMessage({ state }, message: Message) {
if (accessor.settings.chat_sound) {
new Audio('chat.mp3').play().catch(console.error)
}
accessor.chat.addMessage(message)
},
sendMessage(store, content: string) { sendMessage(store, content: string) {
if (!accessor.connected || accessor.user.muted) { if (!accessor.connected || accessor.user.muted) {
return return
} }
$client.sendMessage(EVENT.CHAT.MESSAGE, { content }) $client.sendMessage(EVENT.CHAT.MESSAGE, { content })
}, },
sendEmoji(store, emoji: string) { sendEmote(store, emote: string) {
if (!accessor.connected || !accessor.user.muted) { if (!accessor.connected || accessor.user.muted) {
return return
} }
$client.sendMessage(EVENT.CHAT.EMOTE, { emote })
$client.sendMessage(EVENT.CHAT.EMOJI, { emoji })
}, },
}, },
) )

View File

@ -1,35 +1,22 @@
import { getterTree, mutationTree, actionTree } from 'typed-vuex' import { getterTree, mutationTree, actionTree } from 'typed-vuex'
import { get, set } from '~/utils/localstorage'
import { accessor } from '~/store' import { accessor } from '~/store'
export const namespaced = true export const namespaced = true
export const state = () => { export const state = () => ({
let side = false side: get<boolean>('side', false),
let _side = localStorage.getItem('side') tab: get<string>('tab', 'chat'),
if (_side) {
side = _side === '1'
}
let tab = 'chat'
let _tab = localStorage.getItem('tab')
if (_tab) {
tab = _tab
}
return {
side,
about: false, about: false,
about_page: '', about_page: '',
tab, })
}
}
export const getters = getterTree(state, {}) export const getters = getterTree(state, {})
export const mutations = mutationTree(state, { export const mutations = mutationTree(state, {
setTab(state, tab: string) { setTab(state, tab: string) {
state.tab = tab state.tab = tab
localStorage.setItem('tab', tab) set('tab', tab)
}, },
setAbout(state, page: string) { setAbout(state, page: string) {
state.about_page = page state.about_page = page
@ -39,7 +26,7 @@ export const mutations = mutationTree(state, {
}, },
toggleSide(state) { toggleSide(state) {
state.side = !state.side state.side = !state.side
localStorage.setItem('side', state.side ? '1' : '0') set('side', state.side)
}, },
}) })

View File

@ -12,17 +12,12 @@ import * as client from './client'
export const state = () => ({ export const state = () => ({
connecting: false, connecting: false,
connected: false, connected: false,
locked: false,
}) })
// type RootState = ReturnType<typeof state>
export const getters = {
// connected: (state: RootState) => state.connected
}
export const mutations = mutationTree(state, { export const mutations = mutationTree(state, {
initialiseStore(state) { setLocked(state, locked: boolean) {
console.log('test') state.locked = locked
}, },
setConnnecting(state) { setConnnecting(state) {
@ -37,7 +32,7 @@ export const mutations = mutationTree(state, {
}) })
export const actions = actionTree( export const actions = actionTree(
{ state, getters, mutations }, { state, mutations },
{ {
// //
connect(store, { username, password }: { username: string; password: string }) { connect(store, { username, password }: { username: string; password: string }) {

View File

@ -1,6 +1,6 @@
import { getterTree, mutationTree, actionTree } from 'typed-vuex' import { getterTree, mutationTree, actionTree } from 'typed-vuex'
import { Member } from '~/client/types' import { Member } from '~/neko/types'
import { EVENT } from '~/client/events' import { EVENT } from '~/neko/events'
import { accessor } from '~/store' import { accessor } from '~/store'
export const namespaced = true export const namespaced = true
@ -22,9 +22,6 @@ export const getters = getterTree(state, {
}) })
export const mutations = mutationTree(state, { export const mutations = mutationTree(state, {
clearHost(state) {
state.id = ''
},
setHost(state, host: string | Member) { setHost(state, host: string | Member) {
if (typeof host === 'string') { if (typeof host === 'string') {
state.id = host state.id = host
@ -32,6 +29,10 @@ export const mutations = mutationTree(state, {
state.id = host.id state.id = host.id
} }
}, },
clear(state) {
state.id = ''
},
}) })
export const actions = actionTree( export const actions = actionTree(
@ -68,6 +69,22 @@ export const actions = actionTree(
$client.sendMessage(EVENT.CONTROL.RELEASE) $client.sendMessage(EVENT.CONTROL.RELEASE)
}, },
give({ getters }, member: string | Member) {
if (!accessor.connected || !getters.hosting) {
return
}
if (typeof member === 'string') {
member = accessor.user.members[member]
}
if (!member) {
return
}
$client.sendMessage(EVENT.CONTROL.GIVE, { id: member.id })
},
adminControl() { adminControl() {
if (!accessor.connected || !accessor.user.admin) { if (!accessor.connected || !accessor.user.admin) {
return return
@ -84,6 +101,22 @@ export const actions = actionTree(
$client.sendMessage(EVENT.ADMIN.RELEASE) $client.sendMessage(EVENT.ADMIN.RELEASE)
}, },
adminGive({ getters }, member: string | Member) {
if (!accessor.connected) {
return
}
if (typeof member === 'string') {
member = accessor.user.members[member]
}
if (!member) {
return
}
$client.sendMessage(EVENT.ADMIN.GIVE, { id: member.id })
},
lock() { lock() {
if (!accessor.connected || !accessor.user.admin) { if (!accessor.connected || !accessor.user.admin) {
return return

View File

@ -1,30 +1,43 @@
import { getterTree, mutationTree, actionTree } from 'typed-vuex' import { getterTree, mutationTree } from 'typed-vuex'
import { accessor } from '~/store' import { get, set } from '~/utils/localstorage'
export const namespaced = true export const namespaced = true
export const state = () => ({ export const state = () => {
scroll: 10, return {
scroll_invert: true, scroll: get<number>('scroll', 10),
}) scroll_invert: get<boolean>('scroll_invert', true),
autoplay: get<boolean>('autoplay', true),
ignore_emotes: get<boolean>('ignore_emotes', false),
chat_sound: get<boolean>('chat_sound', true),
}
}
export const getters = getterTree(state, {}) export const getters = getterTree(state, {})
export const mutations = mutationTree(state, { export const mutations = mutationTree(state, {
setScroll(state, scroll: number) { setScroll(state, scroll: number) {
state.scroll = scroll state.scroll = scroll
localStorage.setItem('scroll', `${scroll}`) set('scroll', scroll)
},
setInvert(state, value: boolean) {
state.scroll_invert = value
set('scroll_invert', value)
},
setAutoplay(state, value: boolean) {
state.autoplay = value
set('autoplay', value)
},
setIgnore(state, value: boolean) {
state.ignore_emotes = value
set('ignore_emotes', value)
},
setSound(state, value: boolean) {
state.chat_sound = value
set('chat_sound', value)
}, },
}) })
export const actions = actionTree(
{ state, getters, mutations },
{
initialise() {
const scroll = localStorage.getItem('scroll')
if (scroll) {
accessor.settings.setScroll(parseInt(scroll))
}
},
},
)

View File

@ -1,6 +1,6 @@
import { getterTree, mutationTree, actionTree } from 'typed-vuex' import { getterTree, mutationTree, actionTree } from 'typed-vuex'
import { Member } from '~/client/types' import { Member } from '~/neko/types'
import { EVENT } from '~/client/events' import { EVENT } from '~/neko/events'
import { accessor } from '~/store' import { accessor } from '~/store'
@ -22,6 +22,12 @@ export const getters = getterTree(state, {
}) })
export const mutations = mutationTree(state, { export const mutations = mutationTree(state, {
setIgnored(state, { id, ignored }: { id: string; ignored: boolean }) {
state.members[id] = {
...state.members[id],
ignored,
}
},
setMuted(state, { id, muted }: { id: string; muted: boolean }) { setMuted(state, { id, muted }: { id: string; muted: boolean }) {
state.members[id] = { state.members[id] = {
...state.members[id], ...state.members[id],
@ -56,7 +62,7 @@ export const mutations = mutationTree(state, {
connected: false, connected: false,
} }
}, },
clearMembers(state) { clear(state) {
state.members = {} state.members = {}
}, },
}) })

View File

@ -1,22 +1,9 @@
import { getterTree, mutationTree, actionTree } from 'typed-vuex' import { getterTree, mutationTree, actionTree } from 'typed-vuex'
import { accessor } from '~/store' import { get, set } from '~/utils/localstorage'
export const namespaced = true export const namespaced = true
export const state = () => { export const state = () => ({
let volume = 100
let _volume = localStorage.getItem('volume')
if (_volume) {
volume = parseInt(_volume)
}
let muted = false
let _muted = localStorage.getItem('muted')
if (_muted) {
muted = _muted === '1'
}
return {
index: -1, index: -1,
tracks: [] as MediaStreamTrack[], tracks: [] as MediaStreamTrack[],
streams: [] as MediaStream[], streams: [] as MediaStream[],
@ -24,12 +11,11 @@ export const state = () => {
height: 720, height: 720,
horizontal: 16, horizontal: 16,
vertical: 9, vertical: 9,
volume, volume: get<number>('volume', 100),
muted, muted: get<boolean>('muted', false),
playing: false, playing: false,
playable: false, playable: false,
} })
}
export const getters = getterTree(state, { export const getters = getterTree(state, {
stream: state => state.streams[state.index], stream: state => state.streams[state.index],
@ -58,6 +44,7 @@ export const mutations = mutationTree(state, {
toggleMute(state) { toggleMute(state) {
state.muted = !state.muted state.muted = !state.muted
set('mute', state.muted)
}, },
setPlayable(state, playable: boolean) { setPlayable(state, playable: boolean) {
@ -107,7 +94,7 @@ export const mutations = mutationTree(state, {
setVolume(state, volume: number) { setVolume(state, volume: number) {
state.volume = volume state.volume = volume
localStorage.setItem('volume', `${volume}`) set('volume', volume)
}, },
setStream(state, index: number) { setStream(state, index: number) {
@ -130,15 +117,3 @@ export const mutations = mutationTree(state, {
state.streams = [] state.streams = []
}, },
}) })
export const actions = actionTree(
{ state, getters, mutations },
{
initialise({ commit }) {
const volume = localStorage.getItem('volume')
if (volume) {
accessor.video.setVolume(parseInt(volume))
}
},
},
)

View File

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

View File

@ -0,0 +1,31 @@
export function set<T extends string | number | boolean>(key: string, val: T) {
switch (typeof val) {
case 'number':
localStorage.setItem(key, val.toString())
break
case 'string':
localStorage.setItem(key, val)
break
case 'boolean':
localStorage.setItem(key, val ? '1' : '0')
break
}
}
export function get<T extends string | number | boolean>(key: string, def: T): T {
let store = localStorage.getItem(key)
if (store) {
switch (typeof def) {
case 'number':
return parseInt(store) as T
case 'string':
return store as T
case 'boolean':
return (store === '1') as T
default:
return def
}
}
return def
}

View File

@ -13,8 +13,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"baseUrl": ".", "baseUrl": ".",
"types": [ "types": [
"webpack-env", "webpack-env"
"w3c-image-capture"
], ],
"paths": { "paths": {
"~/*": [ "~/*": [

View File

@ -16,9 +16,10 @@ const CONTROL_LOCKED = "control/locked"
const CONTROL_RELEASE = "control/release" const CONTROL_RELEASE = "control/release"
const CONTROL_REQUEST = "control/request" const CONTROL_REQUEST = "control/request"
const CONTROL_REQUESTING = "control/requesting" const CONTROL_REQUESTING = "control/requesting"
const CONTROL_GIVE = "control/give"
const CHAT_MESSAGE = "chat/message" const CHAT_MESSAGE = "chat/message"
const CHAT_EMOJI = "chat/emoji" const CHAT_EMOTE = "chat/emote"
const ADMIN_BAN = "admin/ban" const ADMIN_BAN = "admin/ban"
const ADMIN_KICK = "admin/kick" const ADMIN_KICK = "admin/kick"
@ -28,3 +29,4 @@ const ADMIN_UNLOCK = "admin/unlock"
const ADMIN_UNMUTE = "admin/unmute" const ADMIN_UNMUTE = "admin/unmute"
const ADMIN_CONTROL = "admin/control" const ADMIN_CONTROL = "admin/control"
const ADMIN_RELEASE = "admin/release" const ADMIN_RELEASE = "admin/release"
const ADMIN_GIVE = "admin/give"

View File

@ -45,6 +45,12 @@ type Control struct {
ID string `json:"id"` ID string `json:"id"`
} }
type ControlTarget struct {
Event string `json:"event"`
ID string `json:"id"`
Target string `json:"target"`
}
type ChatRecieve struct { type ChatRecieve struct {
Event string `json:"event"` Event string `json:"event"`
Content string `json:"content"` Content string `json:"content"`
@ -56,15 +62,15 @@ type ChatSend struct {
Content string `json:"content"` Content string `json:"content"`
} }
type EmojiRecieve struct { type EmoteRecieve struct {
Event string `json:"event"` Event string `json:"event"`
Emoji string `json:"emoji"` Emote string `json:"emote"`
} }
type EmojiSend struct { type EmoteSend struct {
Event string `json:"event"` Event string `json:"event"`
ID string `json:"id"` ID string `json:"id"`
Emoji string `json:"emoji"` Emote string `json:"emote"`
} }
type Admin struct { type Admin struct {

View File

@ -1,13 +1,21 @@
package websocket package websocket
import ( import (
"strings"
"n.eko.moe/neko/internal/event" "n.eko.moe/neko/internal/event"
"n.eko.moe/neko/internal/message" "n.eko.moe/neko/internal/message"
"n.eko.moe/neko/internal/session" "n.eko.moe/neko/internal/session"
) )
func (h *MessageHandler) adminLock(id string, session *session.Session) error { func (h *MessageHandler) adminLock(id string, session *session.Session) error {
if !session.Admin || !h.locked { if !session.Admin {
h.logger.Debug().Msg("user not admin")
return nil
}
if h.locked {
h.logger.Debug().Msg("server already locked...")
return nil return nil
} }
@ -26,7 +34,13 @@ func (h *MessageHandler) adminLock(id string, session *session.Session) error {
} }
func (h *MessageHandler) adminUnlock(id string, session *session.Session) error { func (h *MessageHandler) adminUnlock(id string, session *session.Session) error {
if !session.Admin || !h.locked { if !session.Admin {
h.logger.Debug().Msg("user not admin")
return nil
}
if !h.locked {
h.logger.Debug().Msg("server not locked...")
return nil return nil
} }
@ -46,6 +60,7 @@ func (h *MessageHandler) adminUnlock(id string, session *session.Session) error
func (h *MessageHandler) adminControl(id string, session *session.Session) error { func (h *MessageHandler) adminControl(id string, session *session.Session) error {
if !session.Admin { if !session.Admin {
h.logger.Debug().Msg("user not admin")
return nil return nil
} }
@ -79,6 +94,7 @@ func (h *MessageHandler) adminControl(id string, session *session.Session) error
func (h *MessageHandler) adminRelease(id string, session *session.Session) error { func (h *MessageHandler) adminRelease(id string, session *session.Session) error {
if !session.Admin { if !session.Admin {
h.logger.Debug().Msg("user not admin")
return nil return nil
} }
@ -110,75 +126,28 @@ func (h *MessageHandler) adminRelease(id string, session *session.Session) error
return nil return nil
} }
func (h *MessageHandler) adminBan(id string, session *session.Session, payload *message.Admin) error { func (h *MessageHandler) adminGive(id string, session *session.Session, payload *message.Admin) error {
if !session.Admin { if !session.Admin {
h.logger.Debug().Msg("user not admin")
return nil return nil
} }
target, ok := h.sessions.Get(id) if !h.sessions.Has(payload.ID) {
if !ok { h.logger.Debug().Str("id", payload.ID).Msg("user does not exist")
return nil return nil
} }
if target.Admin { // set host
return nil h.sessions.SetHost(payload.ID)
}
address := target.RemoteAddr()
if address == nil {
return nil
}
h.banned[*address] = true
if err := session.Kick(message.Disconnect{
Event: event.SYSTEM_DISCONNECT,
Message: "You have been banned",
}); err != nil {
return err
}
// let everyone know
if err := h.sessions.Brodcast( if err := h.sessions.Brodcast(
message.AdminTarget{ message.AdminTarget{
Event: event.ADMIN_BAN, Event: event.CONTROL_GIVE,
Target: target.ID,
ID: id, ID: id,
Target: payload.ID,
}, nil); err != nil { }, nil); err != nil {
h.logger.Warn().Err(err).Msgf("brodcasting event %s has failed", event.ADMIN_BAN) h.logger.Warn().Err(err).Msgf("brodcasting event %s has failed", event.CONTROL_LOCKED)
return err
}
return nil
}
func (h *MessageHandler) adminKick(id string, session *session.Session, payload *message.Admin) error {
if !session.Admin {
return nil
}
target, ok := h.sessions.Get(payload.ID)
if !ok {
return nil
}
if target.Admin {
return nil
}
if err := target.Kick(message.Disconnect{
Event: event.SYSTEM_DISCONNECT,
Message: "You have been kicked",
}); err != nil {
return err
}
if err := h.sessions.Brodcast(
message.AdminTarget{
Event: event.ADMIN_KICK,
Target: target.ID,
ID: id,
}, nil); err != nil {
h.logger.Warn().Err(err).Msgf("brodcasting event %s has failed", event.ADMIN_KICK)
return err return err
} }
@ -187,15 +156,18 @@ func (h *MessageHandler) adminKick(id string, session *session.Session, payload
func (h *MessageHandler) adminMute(id string, session *session.Session, payload *message.Admin) error { func (h *MessageHandler) adminMute(id string, session *session.Session, payload *message.Admin) error {
if !session.Admin { if !session.Admin {
h.logger.Debug().Msg("user not admin")
return nil return nil
} }
target, ok := h.sessions.Get(payload.ID) target, ok := h.sessions.Get(payload.ID)
if !ok { if !ok {
h.logger.Debug().Str("id", payload.ID).Msg("can't find session id")
return nil return nil
} }
if target.Admin { if target.Admin {
h.logger.Debug().Msg("target is an admin, baling")
return nil return nil
} }
@ -216,11 +188,13 @@ func (h *MessageHandler) adminMute(id string, session *session.Session, payload
func (h *MessageHandler) adminUnmute(id string, session *session.Session, payload *message.Admin) error { func (h *MessageHandler) adminUnmute(id string, session *session.Session, payload *message.Admin) error {
if !session.Admin { if !session.Admin {
h.logger.Debug().Msg("user not admin")
return nil return nil
} }
target, ok := h.sessions.Get(payload.ID) target, ok := h.sessions.Get(payload.ID)
if !ok { if !ok {
h.logger.Debug().Str("id", payload.ID).Msg("can't find target session")
return nil return nil
} }
@ -238,3 +212,93 @@ func (h *MessageHandler) adminUnmute(id string, session *session.Session, payloa
return nil return nil
} }
func (h *MessageHandler) adminKick(id string, session *session.Session, payload *message.Admin) error {
if !session.Admin {
h.logger.Debug().Msg("user not admin")
return nil
}
target, ok := h.sessions.Get(payload.ID)
if !ok {
h.logger.Debug().Str("id", payload.ID).Msg("can't find session id")
return nil
}
if target.Admin {
h.logger.Debug().Msg("target is an admin, baling")
return nil
}
if err := target.Kick(message.Disconnect{
Event: event.SYSTEM_DISCONNECT,
Message: "You have been kicked",
}); err != nil {
return err
}
if err := h.sessions.Brodcast(
message.AdminTarget{
Event: event.ADMIN_KICK,
Target: target.ID,
ID: id,
}, []string{payload.ID}); err != nil {
h.logger.Warn().Err(err).Msgf("brodcasting event %s has failed", event.ADMIN_KICK)
return err
}
return nil
}
func (h *MessageHandler) adminBan(id string, session *session.Session, payload *message.Admin) error {
if !session.Admin {
h.logger.Debug().Msg("user not admin")
return nil
}
target, ok := h.sessions.Get(payload.ID)
if !ok {
h.logger.Debug().Str("id", payload.ID).Msg("can't find session id")
return nil
}
if target.Admin {
h.logger.Debug().Msg("target is an admin, baling")
return nil
}
remote := target.RemoteAddr()
if remote == nil {
h.logger.Debug().Msg("no remote address, baling")
return nil
}
address := strings.SplitN(*remote, ":", -1)
if len(address[0]) < 1 {
h.logger.Debug().Str("address", *remote).Msg("no remote address, baling")
return nil
}
h.logger.Debug().Str("address", *remote).Msg("adding address to banned")
h.banned[address[0]] = true
if err := target.Kick(message.Disconnect{
Event: event.SYSTEM_DISCONNECT,
Message: "You have been banned",
}); err != nil {
return err
}
if err := h.sessions.Brodcast(
message.AdminTarget{
Event: event.ADMIN_BAN,
Target: target.ID,
ID: id,
}, []string{payload.ID}); err != nil {
h.logger.Warn().Err(err).Msgf("brodcasting event %s has failed", event.ADMIN_BAN)
return err
}
return nil
}

View File

@ -23,15 +23,15 @@ func (h *MessageHandler) chat(id string, session *session.Session, payload *mess
return nil return nil
} }
func (h *MessageHandler) chatEmoji(id string, session *session.Session, payload *message.EmojiRecieve) error { func (h *MessageHandler) chatEmote(id string, session *session.Session, payload *message.EmoteRecieve) error {
if session.Muted { if session.Muted {
return nil return nil
} }
if err := h.sessions.Brodcast( if err := h.sessions.Brodcast(
message.EmojiSend{ message.EmoteSend{
Event: event.CHAT_MESSAGE, Event: event.CHAT_EMOTE,
Emoji: payload.Emoji, Emote: payload.Emote,
ID: id, ID: id,
}, nil); err != nil { }, nil); err != nil {
h.logger.Warn().Err(err).Msgf("brodcasting event %s has failed", event.CONTROL_RELEASE) h.logger.Warn().Err(err).Msgf("brodcasting event %s has failed", event.CONTROL_RELEASE)

View File

@ -10,6 +10,7 @@ func (h *MessageHandler) controlRelease(id string, session *session.Session) err
// check if session is host // check if session is host
if !h.sessions.IsHost(id) { if !h.sessions.IsHost(id) {
h.logger.Debug().Str("id", id).Msg("is not the host")
return nil return nil
} }
@ -31,8 +32,6 @@ func (h *MessageHandler) controlRelease(id string, session *session.Session) err
} }
func (h *MessageHandler) controlRequest(id string, session *session.Session) error { func (h *MessageHandler) controlRequest(id string, session *session.Session) error {
h.logger.Debug().Str("id", id).Msgf("user called %s", event.CONTROL_REQUEST)
// check for host // check for host
if !h.sessions.HasHost() { if !h.sessions.HasHost() {
// set host // set host
@ -76,3 +75,32 @@ func (h *MessageHandler) controlRequest(id string, session *session.Session) err
return nil return nil
} }
func (h *MessageHandler) controlGive(id string, session *session.Session, payload *message.Control) error {
// check if session is host
if !h.sessions.IsHost(id) {
h.logger.Debug().Str("id", id).Msg("is not the host")
return nil
}
if !h.sessions.Has(payload.ID) {
h.logger.Debug().Str("id", payload.ID).Msg("user does not exist")
return nil
}
// set host
h.sessions.SetHost(payload.ID)
// let everyone know
if err := h.sessions.Brodcast(
message.ControlTarget{
Event: event.CONTROL_GIVE,
ID: id,
Target: payload.ID,
}, nil); err != nil {
h.logger.Warn().Err(err).Msgf("brodcasting event %s has failed", event.CONTROL_LOCKED)
return err
}
return nil
}

View File

@ -223,7 +223,6 @@ func (ws *WebSocketHandler) handle(socket *websocket.Conn, id string) {
Msg("recieved message from client") Msg("recieved message from client")
if err := ws.handler.Message(id, raw); err != nil { if err := ws.handler.Message(id, raw); err != nil {
ws.logger.Error().Err(err).Msg("message handler has failed") ws.logger.Error().Err(err).Msg("message handler has failed")
return
} }
case <-cancel: case <-cancel:
return return

View File

@ -2,6 +2,7 @@ package websocket
import ( import (
"encoding/json" "encoding/json"
"strings"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -23,13 +24,24 @@ type MessageHandler struct {
} }
func (h *MessageHandler) SocketConnected(id string, socket *websocket.Conn) (bool, string, error) { func (h *MessageHandler) SocketConnected(id string, socket *websocket.Conn) (bool, string, error) {
ok, banned := h.banned[socket.RemoteAddr().String()] remote := socket.RemoteAddr().String()
if remote != "" {
address := strings.SplitN(remote, ":", -1)
if len(address[0]) < 1 {
h.logger.Debug().Str("address", remote).Msg("no remote address, baling")
} else {
ok, banned := h.banned[address[0]]
if ok && banned { if ok && banned {
return false, "you are banned", nil h.logger.Debug().Str("address", remote).Msg("banned")
return false, "This IP has been banned", nil
}
}
} }
if h.locked { if h.locked {
return false, "stream is currently locked", nil h.logger.Debug().Str("address", remote).Msg("locked")
return false, "Server is currently locked", nil
} }
return true, "", nil return true, "", nil
} }
@ -71,6 +83,12 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
return errors.Wrapf(h.controlRelease(id, session), "%s failed", header.Event) return errors.Wrapf(h.controlRelease(id, session), "%s failed", header.Event)
case event.CONTROL_REQUEST: case event.CONTROL_REQUEST:
return errors.Wrapf(h.controlRequest(id, session), "%s failed", header.Event) return errors.Wrapf(h.controlRequest(id, session), "%s failed", header.Event)
case event.CONTROL_GIVE:
payload := &message.Control{}
return errors.Wrapf(
utils.Unmarshal(payload, raw, func() error {
return h.controlGive(id, session, payload)
}), "%s failed", header.Event)
// Chat Events // Chat Events
case event.CHAT_MESSAGE: case event.CHAT_MESSAGE:
@ -79,11 +97,11 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
utils.Unmarshal(payload, raw, func() error { utils.Unmarshal(payload, raw, func() error {
return h.chat(id, session, payload) return h.chat(id, session, payload)
}), "%s failed", header.Event) }), "%s failed", header.Event)
case event.CHAT_EMOJI: case event.CHAT_EMOTE:
payload := &message.EmojiRecieve{} payload := &message.EmoteRecieve{}
return errors.Wrapf( return errors.Wrapf(
utils.Unmarshal(payload, raw, func() error { utils.Unmarshal(payload, raw, func() error {
return h.chatEmoji(id, session, payload) return h.chatEmote(id, session, payload)
}), "%s failed", header.Event) }), "%s failed", header.Event)
// Admin Events // Admin Events
@ -95,6 +113,12 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
return errors.Wrapf(h.adminControl(id, session), "%s failed", header.Event) return errors.Wrapf(h.adminControl(id, session), "%s failed", header.Event)
case event.ADMIN_RELEASE: case event.ADMIN_RELEASE:
return errors.Wrapf(h.adminRelease(id, session), "%s failed", header.Event) return errors.Wrapf(h.adminRelease(id, session), "%s failed", header.Event)
case event.ADMIN_GIVE:
payload := &message.Admin{}
return errors.Wrapf(
utils.Unmarshal(payload, raw, func() error {
return h.adminGive(id, session, payload)
}), "%s failed", header.Event)
case event.ADMIN_BAN: case event.ADMIN_BAN:
payload := &message.Admin{} payload := &message.Admin{}
return errors.Wrapf( return errors.Wrapf(