Archived
2
0

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

308
client/src/neko/base.ts Normal file
View File

@ -0,0 +1,308 @@
import EventEmitter from 'eventemitter3'
import { OPCODE } from './data'
import { EVENT, WebSocketEvents } from './events'
import {
WebSocketMessages,
WebSocketPayloads,
IdentityPayload,
SignalPayload,
MemberListPayload,
MemberPayload,
ControlPayload,
} from './messages'
export interface BaseEvents {
info: (...message: any[]) => void
warn: (...message: any[]) => void
debug: (...message: any[]) => void
error: (error: Error) => void
}
export abstract class BaseClient extends EventEmitter<BaseEvents> {
protected _ws?: WebSocket
protected _peer?: RTCPeerConnection
protected _channel?: RTCDataChannel
protected _timeout?: NodeJS.Timeout
protected _username?: string
protected _state: RTCIceConnectionState = 'disconnected'
get socketOpen() {
return typeof this._ws !== 'undefined' && this._ws.readyState === WebSocket.OPEN
}
get peerConnected() {
return typeof this._peer !== 'undefined' && this._state === 'connected'
}
get connected() {
return this.peerConnected && this.socketOpen
}
public connect(url: string, password: string, username: string) {
if (this.socketOpen) {
this.emit('warn', `attempting to create websocket while connection open`)
return
}
if (username === '') {
throw new Error('Must add a username') // TODO: Better handleing
}
this._username = username
this[EVENT.CONNECTING]()
try {
this._ws = new WebSocket(`${url}ws?password=${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._timeout = setTimeout(this.onTimeout.bind(this), 5000)
} catch (err) {
this.onDisconnected(err)
}
}
public sendData(event: 'wheel' | 'mousemove', data: { x: number; y: number }): void
public sendData(event: 'mousedown' | 'mouseup' | 'keydown' | 'keyup', data: { key: number }): void
public sendData(event: string, data: any) {
if (!this.connected) {
this.emit('warn', `attemping to send data while dissconneted`)
return
}
let buffer: ArrayBuffer
let payload: DataView
switch (event) {
case 'mousemove':
buffer = new ArrayBuffer(7)
payload = new DataView(buffer)
payload.setUint8(0, OPCODE.MOVE)
payload.setUint16(1, 4, true)
payload.setUint16(3, data.x, true)
payload.setUint16(5, data.y, true)
break
case 'wheel':
buffer = new ArrayBuffer(7)
payload = new DataView(buffer)
payload.setUint8(0, OPCODE.SCROLL)
payload.setUint16(1, 4, true)
payload.setInt16(3, data.x, true)
payload.setInt16(5, data.y, true)
break
case 'keydown':
case 'mousedown':
buffer = new ArrayBuffer(5)
payload = new DataView(buffer)
payload.setUint8(0, OPCODE.KEY_DOWN)
payload.setUint16(1, 1, true)
payload.setUint16(3, data.key, true)
break
case 'keyup':
case 'mouseup':
buffer = new ArrayBuffer(5)
payload = new DataView(buffer)
payload.setUint8(0, OPCODE.KEY_UP)
payload.setUint16(1, 1, true)
payload.setUint16(3, data.key, true)
break
default:
this.emit('warn', `unknown data event: ${event}`)
}
// @ts-ignore
if (typeof buffer !== 'undefined') {
this._channel!.send(buffer)
}
}
public sendMessage(event: WebSocketEvents, payload?: WebSocketPayloads) {
if (!this.connected) {
this.emit('warn', `attemping to send message while dissconneted`)
return
}
this.emit('debug', `sending event '${event}' ${payload ? `with payload: ` : ''}`, payload)
this._ws!.send(JSON.stringify({ event, ...payload }))
}
public createPeer() {
this.emit('debug', `creating peer`)
if (!this.socketOpen) {
this.emit(
'warn',
`attempting to create peer with no websocket: `,
this._ws ? `state: ${this._ws.readyState}` : 'no socket',
)
return
}
if (this.peerConnected) {
this.emit('warn', `attempting to create peer while connected`)
return
}
this._peer = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
})
this._peer.onicecandidate = event => {
if (event.candidate === null && this._peer!.localDescription) {
this.emit('debug', `sending event '${EVENT.SIGNAL.PROVIDE}' with payload`, this._peer!.localDescription.sdp)
this._ws!.send(
JSON.stringify({
event: EVENT.SIGNAL.PROVIDE,
sdp: this._peer!.localDescription.sdp,
}),
)
}
}
this._peer.oniceconnectionstatechange = event => {
this._state = this._peer!.iceConnectionState
this.emit('debug', `peer connection state chagned: ${this._state}`)
switch (this._state) {
case 'connected':
this.onConnected()
break
case 'failed':
this.onDisconnected(new Error('peer failed'))
break
case 'disconnected':
this.onDisconnected(new Error('peer disconnected'))
break
}
}
this._peer.ontrack = this.onTrack.bind(this)
this._peer.addTransceiver('audio', { direction: 'recvonly' })
this._peer.addTransceiver('video', { direction: 'recvonly' })
this._channel = this._peer.createDataChannel('data')
this._channel.onerror = this.onError.bind(this)
this._channel.onmessage = this.onData.bind(this)
this._channel.onclose = this.onDisconnected.bind(this, new Error('peer data channel closed'))
this._peer
.createOffer()
.then(d => this._peer!.setLocalDescription(d))
.catch(err => this.emit('error', err))
}
private setRemoteDescription(payload: SignalPayload) {
if (this.peerConnected) {
this.emit('warn', `received ${event} with no peer!`)
return
}
this._peer!.setRemoteDescription({ type: 'answer', sdp: payload.sdp })
}
private onMessage(e: MessageEvent) {
const { event, ...payload } = JSON.parse(e.data) as WebSocketMessages
this.emit('debug', `received websocket event ${event} ${payload ? `with payload: ` : ''}`, payload)
switch (event) {
case EVENT.IDENTITY.PROVIDE:
this[EVENT.IDENTITY.PROVIDE](payload as IdentityPayload)
this.createPeer()
break
case EVENT.SIGNAL.ANSWER:
this.setRemoteDescription(payload as SignalPayload)
break
default:
// @ts-ignore
if (typeof this[event] === 'function') {
// @ts-ignore
this[event](payload)
} else {
this[EVENT.MESSAGE](event, payload)
}
}
}
private onData(e: MessageEvent) {
this[EVENT.DATA](e.data)
}
private onTrack(event: RTCTrackEvent) {
this.emit('debug', `received ${event.track.kind} track from peer: ${event.track.id}`, event)
const stream = event.streams[0]
if (!stream) {
this.emit('warn', `no stream provided for track ${event.track.id}(${event.track.label})`)
return
}
this[EVENT.TRACK](event)
}
private onError(event: Event) {
this.emit('error', (event as ErrorEvent).error)
}
private onConnected() {
if (this._timeout) {
clearTimeout(this._timeout)
}
if (!this.connected) {
this.emit('warn', `onConnected called while being disconnected`)
return
}
this.emit('debug', `sending event '${EVENT.IDENTITY.DETAILS}' with payload`, { username: this._username })
this._ws!.send(
JSON.stringify({
event: EVENT.IDENTITY.DETAILS,
username: this._username,
}),
)
this.emit('debug', `connected`)
this[EVENT.CONNECTED]()
}
private onTimeout() {
this.emit('debug', `connection timedout`)
if (this._timeout) {
clearTimeout(this._timeout)
}
this.onDisconnected(new Error('connection timeout'))
}
protected onDisconnected(reason?: Error) {
if (this._timeout) {
clearTimeout(this._timeout)
}
if (this.socketOpen) {
try {
this._ws!.close()
} catch (err) {}
this._ws = undefined
}
if (this.peerConnected) {
try {
this._peer!.close()
} catch (err) {}
this._peer = undefined
}
this.emit('debug', `disconnected:`, reason)
this[EVENT.DISCONNECTED](reason)
}
protected [EVENT.MESSAGE](event: string, payload: any) {
this.emit('warn', `unhandled websocket event '${event}':`, payload)
}
protected abstract [EVENT.CONNECTING](): void
protected abstract [EVENT.CONNECTED](): void
protected abstract [EVENT.DISCONNECTED](reason?: Error): void
protected abstract [EVENT.TRACK](event: RTCTrackEvent): void
protected abstract [EVENT.DATA](data: any): void
protected abstract [EVENT.IDENTITY.PROVIDE](payload: IdentityPayload): void
}

6
client/src/neko/data.ts Normal file
View File

@ -0,0 +1,6 @@
export const OPCODE = {
MOVE: 0x01,
SCROLL: 0x02,
KEY_DOWN: 0x03,
KEY_UP: 0x04,
} as const

82
client/src/neko/events.ts Normal file
View File

@ -0,0 +1,82 @@
export const EVENT = {
// Internal Events
CONNECTING: 'CONNECTING',
CONNECTED: 'CONNECTED',
DISCONNECTED: 'DISCONNECTED',
TRACK: 'TRACK',
MESSAGE: 'MESSAGE',
DATA: 'DATA',
// Websocket Events
SYSTEM: {
DISCONNECT: 'system/disconnect',
},
SIGNAL: {
ANSWER: 'signal/answer',
PROVIDE: 'signal/provide',
},
IDENTITY: {
PROVIDE: 'identity/provide',
DETAILS: 'identity/details',
},
MEMBER: {
LIST: 'member/list',
CONNECTED: 'member/connected',
DISCONNECTED: 'member/disconnected',
},
CONTROL: {
LOCKED: 'control/locked',
RELEASE: 'control/release',
REQUEST: 'control/request',
REQUESTING: 'control/requesting',
GIVE: 'control/give',
},
CHAT: {
MESSAGE: 'chat/message',
EMOTE: 'chat/emote',
},
ADMIN: {
BAN: 'admin/ban',
KICK: 'admin/kick',
LOCK: 'admin/lock',
UNLOCK: 'admin/unlock',
MUTE: 'admin/mute',
UNMUTE: 'admin/unmute',
CONTROL: 'admin/control',
RELEASE: 'admin/release',
GIVE: 'admin/give',
},
} as const
export type Events = typeof EVENT
export type WebSocketEvents =
| SystemEvents
| ControlEvents
| IdentityEvents
| MemberEvents
| SignalEvents
| ChatEvents
| 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 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 SignalEvents = typeof EVENT.SIGNAL.ANSWER | typeof EVENT.SIGNAL.PROVIDE
export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
export type AdminEvents =
| typeof EVENT.ADMIN.BAN
| typeof EVENT.ADMIN.KICK
| typeof EVENT.ADMIN.LOCK
| typeof EVENT.ADMIN.UNLOCK
| typeof EVENT.ADMIN.MUTE
| typeof EVENT.ADMIN.UNMUTE
| typeof EVENT.ADMIN.CONTROL
| typeof EVENT.ADMIN.RELEASE
| typeof EVENT.ADMIN.GIVE

458
client/src/neko/index.ts Normal file
View File

@ -0,0 +1,458 @@
import Vue from 'vue'
import EventEmitter from 'eventemitter3'
import { BaseClient, BaseEvents } from './base'
import { Member } from './types'
import { EVENT } from './events'
import { accessor } from '~/store'
import {
DisconnectPayload,
IdentityPayload,
MemberListPayload,
MemberDisconnectPayload,
MemberPayload,
ControlPayload,
ControlTargetPayload,
ChatPayload,
EmotePayload,
AdminPayload,
AdminTargetPayload,
} from './messages'
interface NekoEvents extends BaseEvents {}
export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
private $vue!: Vue
private $accessor!: typeof accessor
init(vue: Vue) {
this.$vue = vue
this.$accessor = vue.$accessor
}
connect(password: string, username: string) {
const url =
process.env.NODE_ENV === 'development'
? `ws://${process.env.VUE_APP_SERVER}/`
: `${/https/gi.test(location.protocol) ? 'wss' : 'ws'}://${location.host}/`
super.connect(url, password, username)
}
private get id() {
return this.$accessor.user.id
}
/////////////////////////////
// Internal Events
/////////////////////////////
protected [EVENT.CONNECTING]() {
this.$accessor.setConnnecting()
}
protected [EVENT.CONNECTED]() {
this.$accessor.setConnected(true)
this.$accessor.setConnected(true)
this.$vue.$notify({
group: 'neko',
type: 'success',
title: 'Successfully connected',
duration: 5000,
speed: 1000,
})
this.$accessor.chat.newMessage({
id: this.id,
content: 'connected',
type: 'event',
created: new Date(),
})
}
protected [EVENT.DISCONNECTED](reason?: Error) {
this.$accessor.setConnected(false)
this.$accessor.remote.clear()
this.$accessor.user.clear()
this.$accessor.video.clear()
this.$accessor.chat.clear()
this.$vue.$notify({
group: 'neko',
type: 'error',
title: `Disconnected:`,
text: reason ? reason.message : undefined,
duration: 5000,
speed: 1000,
})
}
protected [EVENT.TRACK](event: RTCTrackEvent) {
const { track, streams } = event
if (track.kind === 'audio') {
return
}
this.$accessor.video.addTrack([track, streams[0]])
this.$accessor.video.setStream(0)
}
protected [EVENT.DATA](data: any) {}
/////////////////////////////
// System Events
/////////////////////////////
protected [EVENT.SYSTEM.DISCONNECT]({ message }: DisconnectPayload) {
this.onDisconnected(new Error(message))
this.$vue.$swal({
title: 'Disconnected!',
text: message,
icon: 'error',
confirmButtonText: 'ok',
})
}
/////////////////////////////
// Identity Events
/////////////////////////////
protected [EVENT.IDENTITY.PROVIDE]({ id }: IdentityPayload) {
this.$accessor.user.setMember(id)
}
/////////////////////////////
// Member Events
/////////////////////////////
protected [EVENT.MEMBER.LIST]({ members }: MemberListPayload) {
this.$accessor.user.setMembers(members)
}
protected [EVENT.MEMBER.CONNECTED](member: MemberPayload) {
this.$accessor.user.addMember(member)
if (member.id !== this.id) {
this.$accessor.chat.newMessage({
id: member.id,
content: 'connected',
type: 'event',
created: new Date(),
})
}
}
protected [EVENT.MEMBER.DISCONNECTED]({ id }: MemberDisconnectPayload) {
const member = this.member(id)
if (!member) {
return
}
this.$accessor.chat.newMessage({
id: member.id,
content: 'disconnected',
type: 'event',
created: new Date(),
})
this.$accessor.user.delMember(id)
}
/////////////////////////////
// Control Events
/////////////////////////////
protected [EVENT.CONTROL.LOCKED]({ id }: ControlPayload) {
this.$accessor.remote.setHost(id)
const member = this.member(id)
if (!member) {
return
}
if (this.id === id) {
this.$vue.$notify({
group: 'neko',
type: 'info',
title: `You have the controls`,
duration: 5000,
speed: 1000,
})
}
this.$accessor.chat.newMessage({
id: member.id,
content: 'took the controls',
type: 'event',
created: new Date(),
})
}
protected [EVENT.CONTROL.RELEASE]({ id }: ControlPayload) {
this.$accessor.remote.clear()
const member = this.member(id)
if (!member) {
return
}
if (this.id === id) {
this.$vue.$notify({
group: 'neko',
type: 'info',
title: `You released the controls`,
duration: 5000,
speed: 1000,
})
}
this.$accessor.chat.newMessage({
id: member.id,
content: 'released the controls',
type: 'event',
created: new Date(),
})
}
protected [EVENT.CONTROL.REQUEST]({ id }: ControlPayload) {
const member = this.member(id)
if (!member) {
return
}
this.$vue.$notify({
group: 'neko',
type: 'info',
title: `${member.username} has the controls`,
text: 'But I let them know you wanted it',
duration: 5000,
speed: 1000,
})
}
protected [EVENT.CONTROL.REQUESTING]({ id }: ControlPayload) {
const member = this.member(id)
if (!member || member.ignored) {
return
}
this.$vue.$notify({
group: 'neko',
type: 'info',
title: `${member.username} is requesting the controls`,
duration: 5000,
speed: 1000,
})
}
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
/////////////////////////////
protected [EVENT.CHAT.MESSAGE]({ id, content }: ChatPayload) {
const member = this.member(id)
if (!member || member.ignored) {
return
}
this.$accessor.chat.newMessage({
id,
content,
type: 'text',
created: new Date(),
})
}
protected [EVENT.CHAT.EMOTE]({ id, emote }: EmotePayload) {
const member = this.member(id)
if (!member || member.ignored) {
return
}
this.$accessor.chat.newEmote({ type: emote })
}
/////////////////////////////
// Admin Events
/////////////////////////////
protected [EVENT.ADMIN.BAN]({ id, target }: AdminTargetPayload) {
if (!target) {
return
}
const member = this.member(target)
if (!member) {
return
}
this.$accessor.chat.newMessage({
id,
content: `banned ${member.id == this.id ? 'you' : member.username}`,
type: 'event',
created: new Date(),
})
}
protected [EVENT.ADMIN.KICK]({ id, target }: AdminTargetPayload) {
if (!target) {
return
}
const member = this.member(target)
if (!member) {
return
}
this.$accessor.chat.newMessage({
id,
content: `kicked ${member.id == this.id ? 'you' : member.username}`,
type: 'event',
created: new Date(),
})
}
protected [EVENT.ADMIN.MUTE]({ id, target }: AdminTargetPayload) {
if (!target) {
return
}
this.$accessor.user.setMuted({ id: target, muted: true })
const member = this.member(target)
if (!member) {
return
}
this.$accessor.chat.newMessage({
id,
content: `muted ${member.id == this.id ? 'you' : member.username}`,
type: 'event',
created: new Date(),
})
}
protected [EVENT.ADMIN.UNMUTE]({ id, target }: AdminTargetPayload) {
if (!target) {
return
}
this.$accessor.user.setMuted({ id: target, muted: false })
const member = this.member(target)
if (!member) {
return
}
this.$accessor.chat.newMessage({
id,
content: `unmuted ${member.username}`,
type: 'event',
created: new Date(),
})
}
protected [EVENT.ADMIN.LOCK]({ id }: AdminPayload) {
this.$accessor.setLocked(true)
this.$accessor.chat.newMessage({
id,
content: `locked the room`,
type: 'event',
created: new Date(),
})
}
protected [EVENT.ADMIN.UNLOCK]({ id }: AdminPayload) {
this.$accessor.setLocked(false)
this.$accessor.chat.newMessage({
id,
content: `unlocked the room`,
type: 'event',
created: new Date(),
})
}
protected [EVENT.ADMIN.CONTROL]({ id, target }: AdminTargetPayload) {
this.$accessor.remote.setHost(id)
if (!target) {
this.$accessor.chat.newMessage({
id,
content: `force took the controls`,
type: 'event',
created: new Date(),
})
return
}
const member = this.member(target)
if (!member) {
return
}
this.$accessor.chat.newMessage({
id,
content: `took the controls from ${member.id == this.id ? 'you' : member.username}`,
type: 'event',
created: new Date(),
})
}
protected [EVENT.ADMIN.RELEASE]({ id, target }: AdminTargetPayload) {
this.$accessor.remote.clear()
if (!target) {
this.$accessor.chat.newMessage({
id,
content: `force released the controls`,
type: 'event',
created: new Date(),
})
return
}
const member = this.member(target)
if (!member) {
return
}
this.$accessor.chat.newMessage({
id,
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',
created: new Date(),
})
}
// Utilities
protected member(id: string): Member | undefined {
return this.$accessor.user.members[id]
}
}

161
client/src/neko/messages.ts Normal file
View File

@ -0,0 +1,161 @@
import {
EVENT,
WebSocketEvents,
SystemEvents,
ControlEvents,
IdentityEvents,
MemberEvents,
SignalEvents,
ChatEvents,
AdminEvents,
} from './events'
import { Member } from './types'
export type WebSocketMessages =
| WebSocketMessage
| IdentityMessage
| SignalMessage
| MemberListMessage
| MembeConnectMessage
| MembeDisconnectMessage
| ControlMessage
| ChatMessage
export type WebSocketPayloads =
| IdentityPayload
| SignalPayload
| MemberListPayload
| Member
| ControlPayload
| ChatPayload
| ChatSendPayload
| EmojiSendPayload
| AdminPayload
export interface WebSocketMessage {
event: WebSocketEvents | string
}
/*
SYSTEM MESSAGES/PAYLOADS
*/
// system/disconnect
export interface DisconnectMessage extends WebSocketMessage, DisconnectPayload {
event: typeof EVENT.SYSTEM.DISCONNECT
}
export interface DisconnectPayload {
message: string
}
/*
IDENTITY MESSAGES/PAYLOADS
*/
// identity/provide
export interface IdentityMessage extends WebSocketMessage, IdentityPayload {
event: typeof EVENT.IDENTITY.PROVIDE
}
export interface IdentityPayload {
id: string
}
/*
SIGNAL MESSAGES/PAYLOADS
*/
// signal/answer
export interface SignalMessage extends WebSocketMessage, SignalPayload {
event: typeof EVENT.SIGNAL.ANSWER
}
export interface SignalPayload {
sdp: string
}
/*
MEMBER MESSAGES/PAYLOADS
*/
// member/list
export interface MemberListMessage extends WebSocketMessage, MemberListPayload {
event: typeof EVENT.MEMBER.LIST
}
export interface MemberListPayload {
members: Member[]
}
// member/connected
export interface MembeConnectMessage extends WebSocketMessage, MemberPayload {
event: typeof EVENT.MEMBER.CONNECTED
}
export type MemberPayload = Member
// member/disconnected
export interface MembeDisconnectMessage extends WebSocketMessage, MemberPayload {
event: typeof EVENT.MEMBER.DISCONNECTED
}
export interface MemberDisconnectPayload {
id: string
}
/*
CONTROL MESSAGES/PAYLOADS
*/
// control/locked & control/release & control/request
export interface ControlMessage extends WebSocketMessage, ControlPayload {
event: ControlEvents
}
export interface ControlPayload {
id: string
}
export interface ControlTargetPayload {
id: string
target: string
}
/*
CHAT PAYLOADS
*/
// chat/message
export interface ChatMessage extends WebSocketMessage, ChatPayload {
event: typeof EVENT.CHAT.MESSAGE
}
export interface ChatSendPayload {
content: string
}
export interface ChatPayload {
id: string
content: string
}
// chat/emoji
export interface ChatEmoteMessage extends WebSocketMessage, EmotePayload {
event: typeof EVENT.CHAT.EMOTE
}
export interface EmotePayload {
id: string
emote: string
}
export interface EmojiSendPayload {
emote: string
}
/*
ADMIN PAYLOADS
*/
export interface AdminMessage extends WebSocketMessage, AdminPayload {
event: AdminEvents
}
export interface AdminPayload {
id: string
}
export interface AdminTargetMessage extends WebSocketMessage, AdminTargetPayload {
event: AdminEvents
}
export interface AdminTargetPayload {
id: string
target?: string
}

8
client/src/neko/types.ts Normal file
View File

@ -0,0 +1,8 @@
export interface Member {
id: string
username: string
admin: boolean
muted: boolean
connected?: boolean
ignored?: boolean
}