feat(phase6): video grid, noise suppression, whisper system
- New VideoGrid component: live camera feeds (local + remote) in VoicePanel - Camera toggle in VoiceControls: enable/disable video per participant - Noise suppression toggle in VoiceControls + voice store - VOICE_WHISPER WS event: backend routes whisper to target user only - Whisper UI: per-participant whisper button, notification toasts with dismiss - Fix circular import between voice.ts and ws.ts (use lazy accessor)
This commit is contained in:
@@ -142,6 +142,23 @@ func (c *Client) readPump() {
|
|||||||
switch event.Type {
|
switch event.Type {
|
||||||
case EventTypingStart, EventPresenceUpdate:
|
case EventTypingStart, EventPresenceUpdate:
|
||||||
c.Hub.BroadcastEvent(event)
|
c.Hub.BroadcastEvent(event)
|
||||||
|
case EventVoiceWhisper:
|
||||||
|
// Forward voice whispers only to the target user, not broadcast
|
||||||
|
var whisperData struct {
|
||||||
|
TargetUserID string `json:"target_user_id"`
|
||||||
|
FromUserID string `json:"from_user_id"`
|
||||||
|
FromUsername string `json:"from_username"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
if raw, ok := event.Data.(json.RawMessage); ok {
|
||||||
|
if err := json.Unmarshal(raw, &whisperData); err == nil {
|
||||||
|
whisperData.FromUserID = c.UserID
|
||||||
|
c.Hub.SendToUser(whisperData.TargetUserID, Event{
|
||||||
|
Type: EventVoiceWhisper,
|
||||||
|
Data: whisperData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
|
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
EventVoiceLeave = "VOICE_LEAVE"
|
EventVoiceLeave = "VOICE_LEAVE"
|
||||||
EventVoiceMute = "VOICE_MUTE"
|
EventVoiceMute = "VOICE_MUTE"
|
||||||
EventVoiceDeafen = "VOICE_DEAFEN"
|
EventVoiceDeafen = "VOICE_DEAFEN"
|
||||||
|
EventVoiceWhisper = "VOICE_WHISPER"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents a WebSocket event sent to clients.
|
// Event represents a WebSocket event sent to clients.
|
||||||
|
|||||||
@@ -227,6 +227,28 @@ func (h *Hub) ClientCount() int {
|
|||||||
return len(h.clients)
|
return len(h.clients)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendToUser sends an event only to the specified user's connected client(s).
|
||||||
|
func (h *Hub) SendToUser(userID string, event Event) {
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to marshal event", "type", event.Type, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
for client := range h.clients {
|
||||||
|
if client.UserID == userID {
|
||||||
|
select {
|
||||||
|
case client.send <- data:
|
||||||
|
default:
|
||||||
|
go func(c *Client) { h.unregister <- c }(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshUserServers loads the server membership for a user from the database
|
// RefreshUserServers loads the server membership for a user from the database
|
||||||
// and caches it. Call when a client connects or when server membership changes.
|
// and caches it. Call when a client connects or when server membership changes.
|
||||||
func (h *Hub) RefreshUserServers(userID string) {
|
func (h *Hub) RefreshUserServers(userID string) {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Track } from 'livekit-client';
|
||||||
|
import type { Participant, TrackPublication, Room } from 'livekit-client';
|
||||||
|
import { useVoiceStore } from '../stores/voice.ts';
|
||||||
|
|
||||||
|
function VideoTile({ participant, isLocal, room }: { participant: Participant; isLocal: boolean; room: Room }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = videoRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
let pub: TrackPublication | undefined;
|
||||||
|
|
||||||
|
const attachTrack = () => {
|
||||||
|
pub = participant.getTrackPublication(Track.Source.Camera);
|
||||||
|
|
||||||
|
if (pub?.track && el) {
|
||||||
|
pub.track.attach(el);
|
||||||
|
el.muted = isLocal;
|
||||||
|
el.play().catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const detachTrack = () => {
|
||||||
|
if (pub?.track && el) {
|
||||||
|
pub.track.detach(el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
attachTrack();
|
||||||
|
|
||||||
|
// Listen for track publications changing on this participant via room events
|
||||||
|
const handlePublished = (pub: TrackPublication) => {
|
||||||
|
pub.track?.attach(el);
|
||||||
|
el.play().catch(() => {});
|
||||||
|
};
|
||||||
|
room.on('trackPublished' as any, handlePublished);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
detachTrack();
|
||||||
|
room.off('trackPublished' as any, handlePublished);
|
||||||
|
};
|
||||||
|
}, [participant, isLocal, room]);
|
||||||
|
|
||||||
|
const username = participant.name || participant.identity;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative bg-gb-bg rounded-sm overflow-hidden border border-gb-bg-t aspect-video flex items-center justify-center">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-1 left-1 text-xxs text-gb-fg-s bg-gb-bg/70 px-1 py-0.5 rounded-sm">
|
||||||
|
{username}{isLocal ? ' (you)' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoGrid() {
|
||||||
|
const room = useVoiceStore((s) => s._room);
|
||||||
|
const isVideoOn = useVoiceStore((s) => s.isVideoOn);
|
||||||
|
|
||||||
|
if (!room || !isVideoOn) return null;
|
||||||
|
|
||||||
|
const participants: Participant[] = [
|
||||||
|
room.localParticipant,
|
||||||
|
...Array.from(room.remoteParticipants.values()),
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasCam = participants.filter((p) => {
|
||||||
|
const pub = p.getTrackPublication(Track.Source.Camera);
|
||||||
|
return pub && !pub.isMuted;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasCam.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-1.5 border-b border-gb-bg-t">
|
||||||
|
<div className="text-xxs text-gb-fg-f mb-1">
|
||||||
|
── video [{hasCam.length}] ──
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`grid gap-1 ${
|
||||||
|
hasCam.length <= 2
|
||||||
|
? 'grid-cols-2'
|
||||||
|
: hasCam.length <= 4
|
||||||
|
? 'grid-cols-2'
|
||||||
|
: 'grid-cols-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<VideoTile
|
||||||
|
key={p.identity}
|
||||||
|
participant={p}
|
||||||
|
isLocal={p.identity === room.localParticipant.identity}
|
||||||
|
room={room}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ export function VoiceControls() {
|
|||||||
const isDeafened = useVoiceStore((state) => state.isDeafened);
|
const isDeafened = useVoiceStore((state) => state.isDeafened);
|
||||||
const isVideoOn = useVoiceStore((state) => state.isVideoOn);
|
const isVideoOn = useVoiceStore((state) => state.isVideoOn);
|
||||||
const isScreenSharing = useVoiceStore((state) => state.isScreenSharing);
|
const isScreenSharing = useVoiceStore((state) => state.isScreenSharing);
|
||||||
|
const noiseSuppression = useVoiceStore((state) => state.noiseSuppression);
|
||||||
const toggleMute = useVoiceStore((state) => state.toggleMute);
|
const toggleMute = useVoiceStore((state) => state.toggleMute);
|
||||||
const toggleDeafen = useVoiceStore((state) => state.toggleDeafen);
|
const toggleDeafen = useVoiceStore((state) => state.toggleDeafen);
|
||||||
const toggleVideo = useVoiceStore((state) => state.toggleVideo);
|
const toggleVideo = useVoiceStore((state) => state.toggleVideo);
|
||||||
const toggleScreenShare = useVoiceStore((state) => state.toggleScreenShare);
|
const toggleScreenShare = useVoiceStore((state) => state.toggleScreenShare);
|
||||||
|
const toggleNoiseSuppression = useVoiceStore((state) => state.toggleNoiseSuppression);
|
||||||
const leaveVoice = useVoiceStore((state) => state.leaveVoice);
|
const leaveVoice = useVoiceStore((state) => state.leaveVoice);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +59,17 @@ export function VoiceControls() {
|
|||||||
>
|
>
|
||||||
[share]
|
[share]
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleNoiseSuppression}
|
||||||
|
className={`px-1.5 py-0.5 rounded-sm transition-colors ${
|
||||||
|
noiseSuppression
|
||||||
|
? 'text-gb-green bg-gb-bg-s border border-gb-green'
|
||||||
|
: 'text-gb-fg-s hover:text-gb-green hover:bg-gb-bg-t border border-transparent'
|
||||||
|
}`}
|
||||||
|
title={noiseSuppression ? 'Noise suppression on' : 'Noise suppression off'}
|
||||||
|
>
|
||||||
|
[ns{noiseSuppression ? '●' : '○'}]
|
||||||
|
</button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
<button
|
||||||
onClick={leaveVoice}
|
onClick={leaveVoice}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useVoiceStore } from '../stores/voice.ts';
|
import { useVoiceStore } from '../stores/voice.ts';
|
||||||
|
import { useAuthStore } from '../stores/auth.ts';
|
||||||
import { VoiceControls } from './VoiceControls.tsx';
|
import { VoiceControls } from './VoiceControls.tsx';
|
||||||
|
import { VideoGrid } from './VideoGrid.tsx';
|
||||||
|
|
||||||
export function VoicePanel() {
|
export function VoicePanel() {
|
||||||
const isConnected = useVoiceStore((state) => state.isConnected);
|
const isConnected = useVoiceStore((state) => state.isConnected);
|
||||||
@@ -7,6 +9,10 @@ export function VoicePanel() {
|
|||||||
const currentRoomName = useVoiceStore((state) => state.currentRoomName);
|
const currentRoomName = useVoiceStore((state) => state.currentRoomName);
|
||||||
const participants = useVoiceStore((state) => state.participants);
|
const participants = useVoiceStore((state) => state.participants);
|
||||||
const error = useVoiceStore((state) => state.error);
|
const error = useVoiceStore((state) => state.error);
|
||||||
|
const whispers = useVoiceStore((state) => state.whispers);
|
||||||
|
const whisperTo = useVoiceStore((state) => state.whisperTo);
|
||||||
|
const dismissWhisper = useVoiceStore((state) => state.dismissWhisper);
|
||||||
|
const currentUserId = useAuthStore((state) => state.user?.id);
|
||||||
|
|
||||||
if (!currentRoom) return null;
|
if (!currentRoom) return null;
|
||||||
|
|
||||||
@@ -38,6 +44,31 @@ export function VoicePanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Incoming whispers */}
|
||||||
|
{whispers.length > 0 && (
|
||||||
|
<div className="px-3 py-1.5 border-b border-gb-bg-t">
|
||||||
|
<div className="text-xxs text-gb-fg-f mb-1">
|
||||||
|
── whispers ──
|
||||||
|
</div>
|
||||||
|
{whispers.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.timestamp}
|
||||||
|
className="flex items-center gap-2 py-0.5 text-xs text-gb-orange"
|
||||||
|
>
|
||||||
|
<span>🔊</span>
|
||||||
|
<span className="font-bold">{w.fromUsername}</span>
|
||||||
|
<span className="text-gb-fg-s truncate">{w.message || 'whispered'}</span>
|
||||||
|
<button
|
||||||
|
className="ml-auto text-xxs text-gb-fg-f hover:text-gb-fg-s"
|
||||||
|
onClick={() => dismissWhisper(w.timestamp)}
|
||||||
|
>
|
||||||
|
[x]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Participants */}
|
{/* Participants */}
|
||||||
<div className="px-3 py-1.5 border-b border-gb-bg-t">
|
<div className="px-3 py-1.5 border-b border-gb-bg-t">
|
||||||
<div className="text-xxs text-gb-fg-f mb-1">
|
<div className="text-xxs text-gb-fg-f mb-1">
|
||||||
@@ -46,7 +77,9 @@ export function VoicePanel() {
|
|||||||
{participants.length === 0 && (
|
{participants.length === 0 && (
|
||||||
<div className="text-xxs text-gb-fg-f">[empty channel]</div>
|
<div className="text-xxs text-gb-fg-f">[empty channel]</div>
|
||||||
)}
|
)}
|
||||||
{participants.map((p) => (
|
{participants.map((p) => {
|
||||||
|
const isMe = p.identity === currentUserId;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.identity}
|
key={p.identity}
|
||||||
className="flex items-center gap-2 py-0.5 text-xs"
|
className="flex items-center gap-2 py-0.5 text-xs"
|
||||||
@@ -73,10 +106,23 @@ export function VoicePanel() {
|
|||||||
{p.isSpeaking && (
|
{p.isSpeaking && (
|
||||||
<span className="text-xxs text-gb-green">[speaking]</span>
|
<span className="text-xxs text-gb-green">[speaking]</span>
|
||||||
)}
|
)}
|
||||||
|
{!isMe && (
|
||||||
|
<button
|
||||||
|
className="ml-auto text-xxs text-gb-fg-f hover:text-gb-orange"
|
||||||
|
onClick={() => whisperTo(p.identity, p.username)}
|
||||||
|
title={`Whisper to ${p.username}`}
|
||||||
|
>
|
||||||
|
[whisper]
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Video grid */}
|
||||||
|
<VideoGrid />
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<VoiceControls />
|
<VoiceControls />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
import { api } from '../lib/api.ts';
|
import { api } from '../lib/api.ts';
|
||||||
import { useWebSocketStore } from './ws.ts';
|
import { useWebSocketStore } from './ws.ts';
|
||||||
|
import { useAuthStore } from './auth.ts';
|
||||||
|
|
||||||
export interface VoiceParticipant {
|
export interface VoiceParticipant {
|
||||||
identity: string;
|
identity: string;
|
||||||
@@ -19,6 +20,13 @@ export interface VoiceParticipant {
|
|||||||
isSpeaking: boolean;
|
isSpeaking: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WhisperNotification {
|
||||||
|
fromUserId: string;
|
||||||
|
fromUsername: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface VoiceTokenResponse {
|
interface VoiceTokenResponse {
|
||||||
token: string;
|
token: string;
|
||||||
livekit_url: string;
|
livekit_url: string;
|
||||||
@@ -32,16 +40,22 @@ interface VoiceState {
|
|||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
isVideoOn: boolean;
|
isVideoOn: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
|
noiseSuppression: boolean;
|
||||||
participants: VoiceParticipant[];
|
participants: VoiceParticipant[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
_room: Room | null;
|
_room: Room | null;
|
||||||
isJoining: boolean;
|
isJoining: boolean;
|
||||||
|
whispers: WhisperNotification[];
|
||||||
joinVoice: (channelId: string, channelName: string) => Promise<void>;
|
joinVoice: (channelId: string, channelName: string) => Promise<void>;
|
||||||
leaveVoice: () => Promise<void>;
|
leaveVoice: () => Promise<void>;
|
||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
toggleDeafen: () => void;
|
toggleDeafen: () => void;
|
||||||
toggleVideo: () => void;
|
toggleVideo: () => void;
|
||||||
toggleScreenShare: () => void;
|
toggleScreenShare: () => void;
|
||||||
|
toggleNoiseSuppression: () => void;
|
||||||
|
_addWhisper: (w: WhisperNotification) => void;
|
||||||
|
whisperTo: (targetUserId: string, targetUsername: string, message?: string) => void;
|
||||||
|
dismissWhisper: (timestamp: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function participantToVoice(p: Participant): VoiceParticipant {
|
function participantToVoice(p: Participant): VoiceParticipant {
|
||||||
@@ -62,10 +76,12 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
isDeafened: false,
|
isDeafened: false,
|
||||||
isVideoOn: false,
|
isVideoOn: false,
|
||||||
isScreenSharing: false,
|
isScreenSharing: false,
|
||||||
|
noiseSuppression: true,
|
||||||
participants: [],
|
participants: [],
|
||||||
error: null,
|
error: null,
|
||||||
_room: null,
|
_room: null,
|
||||||
isJoining: false,
|
isJoining: false,
|
||||||
|
whispers: [],
|
||||||
|
|
||||||
joinVoice: async (channelId, channelName) => {
|
joinVoice: async (channelId, channelName) => {
|
||||||
if (get().isJoining) {
|
if (get().isJoining) {
|
||||||
@@ -290,4 +306,50 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
await room.localParticipant.setScreenShareEnabled(newState);
|
await room.localParticipant.setScreenShareEnabled(newState);
|
||||||
set({ isScreenSharing: newState });
|
set({ isScreenSharing: newState });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleNoiseSuppression: async () => {
|
||||||
|
const room = get()._room;
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
const newState = !get().noiseSuppression;
|
||||||
|
set({ noiseSuppression: newState });
|
||||||
|
|
||||||
|
// Apply noise suppression + echo cancellation to local audio track
|
||||||
|
try {
|
||||||
|
const audioTrack = room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||||
|
if (audioTrack?.track?.mediaStreamTrack) {
|
||||||
|
await audioTrack.track.mediaStreamTrack.applyConstraints({
|
||||||
|
echoCancellation: newState,
|
||||||
|
noiseSuppression: newState,
|
||||||
|
autoGainControl: newState,
|
||||||
|
} as MediaTrackConstraints);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[voice] failed to update audio processing:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_addWhisper: (w) => {
|
||||||
|
set((state) => ({ whispers: [...state.whispers, w] }));
|
||||||
|
},
|
||||||
|
|
||||||
|
whisperTo: (targetUserId, targetUsername, message) => {
|
||||||
|
const wsSend = useWebSocketStore.getState().send;
|
||||||
|
const me = useAuthStore.getState().user;
|
||||||
|
wsSend({
|
||||||
|
type: 'VOICE_WHISPER',
|
||||||
|
payload: {
|
||||||
|
target_user_id: targetUserId,
|
||||||
|
target_username: targetUsername,
|
||||||
|
from_username: me?.username || 'unknown',
|
||||||
|
message: message || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissWhisper: (timestamp) => {
|
||||||
|
set((state) => ({
|
||||||
|
whispers: state.whispers.filter((w) => w.timestamp !== timestamp),
|
||||||
|
}));
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useChannelStore } from './channel.ts';
|
|||||||
import { useServerStore } from './server.ts';
|
import { useServerStore } from './server.ts';
|
||||||
import { usePresenceStore } from './presence.ts';
|
import { usePresenceStore } from './presence.ts';
|
||||||
import { useTypingStore } from './typing.ts';
|
import { useTypingStore } from './typing.ts';
|
||||||
|
import { useVoiceStore } from './voice.ts';
|
||||||
import type { Message } from './message.ts';
|
import type { Message } from './message.ts';
|
||||||
import type { Channel } from './channel.ts';
|
import type { Channel } from './channel.ts';
|
||||||
import type { Server } from './server.ts';
|
import type { Server } from './server.ts';
|
||||||
@@ -176,6 +177,17 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'VOICE_WHISPER': {
|
||||||
|
// Forwarded from backend — only intended recipient gets this
|
||||||
|
const { from_user_id, from_username, message } = data.payload as Record<string, string>;
|
||||||
|
useVoiceStore.getState()._addWhisper({
|
||||||
|
fromUserId: from_user_id || '',
|
||||||
|
fromUsername: from_username || 'unknown',
|
||||||
|
message: message || '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user