Full Stack: Join VC + Leave (Backend)

This commit is contained in:
ADAMJR 2021-10-04 15:46:29 +01:00
parent fbf2f4e09f
commit d1a1318275
14 changed files with 141 additions and 20 deletions

View File

@ -10,10 +10,10 @@ export default class Channels extends DBWrapper<string, ChannelDocument> {
return channel;
}
public async getText(id: string) {
public async getText(id: string | undefined) {
return await this.get(id) as TextChannelDocument;
}
public async getVoice(id: string) {
public async getVoice(id: string | undefined) {
return await this.get(id) as VoiceChannelDocument;
}
@ -33,13 +33,11 @@ export default class Channels extends DBWrapper<string, ChannelDocument> {
return this.create({ guildId, type: 'VOICE' }) as Promise<VoiceChannelDocument>;
}
public async joinVC(channelId: string, userId: string) {
const channel = await this.getVoice(channelId);
public async joinVC(channel: VoiceChannelDocument, userId: string) {
channel.userIds.push(userId);
return await channel.save();
}
public async leaveVC(channelId: string, userId: string) {
const channel = await this.getVoice(channelId);
public async leaveVC(channel: VoiceChannelDocument, userId: string) {
const index = channel.userIds.indexOf(userId);
channel.userIds.splice(index, 1);
return await channel.save();

View File

@ -88,6 +88,11 @@ export const User = model<UserDocument>('user', new Schema({
validate: [patterns.username, `Invalid username`],
},
verified: Boolean,
voice: {
type: Object,
required: [true, 'Voice state is required'],
default: {} as UserTypes.VoiceState,
},
}, { toJSON: { getters: true } })
.plugin(uniqueValidator)
.plugin(passportLocalMongoose, { usernameField: 'email' })

View File

@ -3,10 +3,6 @@ export class VoiceService {
public add(channelId: string, data: VoiceData) {
const channelConnections = this.getOrCreate(channelId);
const doesExist = channelConnections.some(c => c.userId === data.userId);
if (doesExist)
throw new TypeError('User already connected to voice');
channelConnections.push(data);
this.connections.set(channelId, channelConnections);
}

View File

@ -6,14 +6,16 @@ import { WSEvent } from './ws-event';
import { WebSocket } from '../websocket';
import { Socket } from 'socket.io';
import { VoiceService } from '../../voice/voice-service';
import Users from '../../data/users';
import { validateUser } from '../../rest/modules/middleware';
export default class implements WSEvent<'CHANNEL_JOIN'> {
on = 'CHANNEL_JOIN' as const;
constructor(
private channels = Deps.get<Channels>(Channels),
private guard = Deps.get<WSGuard>(WSGuard),
private voice = Deps.get<VoiceService>(VoiceService),
private users = Deps.get<Users>(Users),
) {}
public async invoke(ws: WebSocket, client: Socket, { channelId }: WS.Params.ChannelJoin) {
@ -23,8 +25,35 @@ export default class implements WSEvent<'CHANNEL_JOIN'> {
// TODO: validate can join
const userId = ws.sessions.get(client.id);
await this.channels.joinVC(channelId, userId);
// join voice server
const doesExist = channel.userIds.includes(userId);
if (doesExist)
throw new TypeError('User already connected to voice');
this.voice.add(channelId, { stream: null, userId });
await client.join(channelId);
await this.channels.joinVC(channel, userId);
const user = await this.updateVoiceState(userId, channelId);
ws.io
.to(channel.guildId)
.emit('CHANNEL_UPDATE', {
channelId: channel.id,
partialChannel: { userIds: channel.userIds },
} as WS.Args.ChannelUpdate);
ws.io
.to(channel.id)
.emit('VOICE_STATE_UPDATE', {
userId: user.id,
voice: user.voice,
} as WS.Args.VoiceStateUpdate);
}
private async updateVoiceState(userId: string, channelId: string) {
const user = await this.users.getSelf(userId);
user.voice = { channelId };
return await user.save();
}
}

View File

@ -0,0 +1,60 @@
import Channels from '../../data/channels';
import { WS } from '../../types/ws';
import Deps from '../../utils/deps';
import { WSGuard } from '../modules/ws-guard';
import { WSEvent } from './ws-event';
import { WebSocket } from '../websocket';
import { Socket } from 'socket.io';
import { VoiceService } from '../../voice/voice-service';
import Users from '../../data/users';
import { SelfUserDocument } from '../../data/models/user';
export default class implements WSEvent<'CHANNEL_LEAVE'> {
on = 'CHANNEL_LEAVE' as const;
constructor(
private channels = Deps.get<Channels>(Channels),
private guard = Deps.get<WSGuard>(WSGuard),
private voice = Deps.get<VoiceService>(VoiceService),
private users = Deps.get<Users>(Users),
) {}
public async invoke(ws: WebSocket, client: Socket) {
const userId = ws.sessions.get(client.id);
const user = await this.users.getSelf(userId);
const channel = await this.channels.getVoice(user.voice.channelId);
if (channel.type !== 'VOICE')
throw new TypeError('You cannot leave a non-voice channel');
// TODO: validate can join
await this.channels.leaveVC(channel, userId);
const doesExist = channel.userIds.includes(userId);
if (!doesExist)
throw new TypeError('User not connected to voice');
// join voice server
this.voice.remove(channel.id, userId);
await client.leave(channel.id);
await this.updateVoiceState(user);
ws.io
.to(channel.guildId)
.emit('CHANNEL_UPDATE', {
channelId: channel.id,
partialChannel: { userIds: channel.userIds },
} as WS.Args.ChannelUpdate);
ws.io
.to(channel.id)
.emit('VOICE_STATE_UPDATE', {
userId: user.id,
voice: user.voice,
} as WS.Args.VoiceStateUpdate);
}
private async updateVoiceState(user: SelfUserDocument) {
user.voice = { channelId: undefined };
await user.save();
}
}

View File

@ -16,13 +16,13 @@ export default class implements WSEvent<'CHANNEL_UPDATE'> {
) {}
public async invoke(ws: WebSocket, client: Socket, { name, summary, overrides, channelId }: WS.Params.ChannelUpdate) {
const channel = await this.channels.getText(channelId);
const channel = await this.channels.get(channelId);
await this.guard.validateCan(client, channel.guildId, 'MANAGE_CHANNELS');
const partialChannel: Partial<Entity.Channel> = {};
if (name) partialChannel.name = name;
if (overrides) partialChannel.overrides = overrides;
partialChannel.summary = summary;
if (summary) partialChannel.summary = summary;
await channel.updateOne(partialChannel as any, { runValidators: true });
ws.io

View File

@ -5,10 +5,13 @@ import { ContextMenuTrigger } from 'react-contextmenu';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import usePerms from '../../../hooks/use-perms';
import { joinVoiceChannel } from '../../../store/channels';
import { getChannel, getChannelUsers, joinVoiceChannel } from '../../../store/channels';
import { getGuildChannels } from '../../../store/guilds';
import { actions as ui } from '../../../store/ui';
import ChannelMenu from '../../ctx-menus/channel-menu';
import Username from '../../user/username';
import './channel-tabs.scoped.css';
const ChannelTabs: React.FunctionComponent = () => {
const dispatch = useDispatch();
@ -30,6 +33,14 @@ const ChannelTabs: React.FunctionComponent = () => {
dispatch(joinVoiceChannel(channel.id));
};
const VCMembers = () => {
const users = useSelector(getChannelUsers(channel.id));
if (channel.type !== 'VOICE') return null;
return <div className="p-2 pl-3">{users.map(u => <Username user={u} />)}</div>;
};
return (
<ContextMenuTrigger key={channel.id} id={channel.id}>
<Link
@ -54,11 +65,12 @@ const ChannelTabs: React.FunctionComponent = () => {
</span>
</span>
</Link>
<VCMembers />
<ChannelMenu channel={channel} />
</ContextMenuTrigger>
);
}
return <>{guildChannels.map(c => <ChannelTab channel={c} />)}</>
return <>{guildChannels.map(c => <ChannelTab key={c.id} channel={c} />)}</>
};
export default ChannelTabs;

View File

@ -4,8 +4,6 @@ import { actions as ui } from '../../../store/ui';
import GuildDropdown from '../../dropdowns/guild-dropdown';
import ChannelTabs from './channel-tabs';
import './sidebar-content.scoped.css';
const SidebarContent: React.FunctionComponent = () => {
const dispatch = useDispatch();
return (

View File

@ -8,7 +8,7 @@ import Image from '../utils/image';
export interface UsernameProps {
user: Entity.User;
guild?: Entity.Guild;
size?: 'md' | 'lg';
size?: 'sm' | 'md' | 'lg';
}
const Username: React.FunctionComponent<UsernameProps> = ({ guild, user, size = 'md' }) => {

View File

@ -142,6 +142,11 @@ const WSListener: React.FunctionComponent = () => {
dispatch(auth.updatedUser(args));
dispatch(users.updated(args));
});
ws.on('VOICE_STATE_UPDATE', (args) => {
const data = { userId: args.userId, partialUser: { voice: args.voice } };
dispatch(auth.updatedUser(data));
dispatch(users.updated(data ));
});
dispatch(meta.listenedToWS());
}, [hasListened]);

View File

@ -62,4 +62,13 @@ export const getChannel = (id: string) =>
createSelector<Store.AppState, Entity.Channel[], Entity.Channel | undefined>(
state => state.entities.channels,
channels => channels.find(c => c.id === id),
);
export const getChannelUsers = (channelId: string) =>
createSelector<Store.AppState, { channels, users }, Entity.User[]>(
state => ({ channels: state.entities.channels, users: state.entities.users }),
({ channels, users }) => {
const vc = channels.find(c => c.id === channelId) as ChannelTypes.Voice;
return vc.userIds.map(id => users.find(u => u.id === id))
},
);

4
types/entity.d.ts vendored
View File

@ -71,6 +71,7 @@ declare namespace Entity {
guildIds: string[];
status: UserTypes.StatusType;
username: string;
voice: UserTypes.VoiceState;
}
}
@ -140,4 +141,7 @@ declare namespace UserTypes {
userIds: string[];
};
}
export interface VoiceState {
channelId?: string;
}
}

7
types/ws.d.ts vendored
View File

@ -104,7 +104,8 @@ declare namespace WS {
'USER_DELETE': {};
/** Called the client user settings are updated. */
'USER_UPDATE': WS.Args.UserUpdate;
/** Called when a websocket message is sent. */
/** Called when a user voice state is updated in the client's voice channel. */
'VOICE_STATE_UPDATE': WS.Args.VoiceStateUpdate;
'error': object;
}
@ -355,6 +356,10 @@ declare namespace WS {
userId: string;
partialUser: Partial<UserTypes.Self>;
}
export interface VoiceStateUpdate {
userId: string;
voice: UserTypes.VoiceState;
}
}
}