feat(webrtc): add admin option to mute other participants
This commit is contained in:
@@ -99,6 +99,33 @@ func (c *Client) ListParticipants(roomName string) ([]*livekit.ParticipantInfo,
|
||||
return resp.Participants, nil
|
||||
}
|
||||
|
||||
func (c *Client) MuteParticipant(roomName string, identity string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("voice client not configured")
|
||||
}
|
||||
|
||||
participant, err := c.roomClient.GetParticipant(context.Background(), &livekit.RoomParticipantIdentity{
|
||||
Room: roomName,
|
||||
Identity: identity,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get participant: %w", err)
|
||||
}
|
||||
|
||||
for _, track := range participant.Tracks {
|
||||
_, err := c.roomClient.MutePublishedTrack(context.Background(), &livekit.MuteRoomTrackRequest{
|
||||
Room: roomName,
|
||||
Identity: identity,
|
||||
TrackSid: track.Sid,
|
||||
Muted: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mute track %s: %w", track.Sid, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
@@ -7,27 +7,32 @@ import (
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
client *Client
|
||||
hub *gateway.Hub
|
||||
db *sql.DB
|
||||
client *Client
|
||||
hub *gateway.Hub
|
||||
checker *permissions.Checker
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
client: client,
|
||||
hub: hub,
|
||||
db: db,
|
||||
client: client,
|
||||
hub: hub,
|
||||
checker: permissions.NewChecker(db),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth)
|
||||
r.Post("/token", h.GetToken)
|
||||
r.Get("/rooms/{roomID}/participants", h.GetParticipants)
|
||||
r.Post("/rooms/{roomID}/participants/{userID}/mute", h.MuteParticipant)
|
||||
}
|
||||
|
||||
type tokenRequest struct {
|
||||
@@ -148,3 +153,53 @@ func (h *Handler) GetParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// @Summary Mute a participant
|
||||
// @Description Mute a participant's tracks in a voice room (requires MUTE_MEMBERS permission)
|
||||
// @Tags voice
|
||||
// @Security SessionAuth
|
||||
// @Param roomID path string true "Room ID"
|
||||
// @Param userID path string true "User ID (Identity)"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /voice/rooms/{roomID}/participants/{userID}/mute [post]
|
||||
func (h *Handler) MuteParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
roomID := chi.URLParam(r, "roomID")
|
||||
targetIdentity := chi.URLParam(r, "userID")
|
||||
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the server_id for this channel to check permissions.
|
||||
var serverID string
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1`, roomID).Scan(&serverID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, `{"error":"room not found or not a server channel"}`, http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hasPerm, err := h.checker.CheckChannelPermission(r.Context(), serverID, userID, roomID, permissions.MUTE_MEMBERS)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to check permissions"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden: missing MUTE_MEMBERS permission"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.client.MuteParticipant(roomID, targetIdentity); err != nil {
|
||||
http.Error(w, `{"error":"failed to mute participant"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useVoiceStore } from '../stores/voice.ts';
|
||||
import { useAuthStore } from '../stores/auth.ts';
|
||||
import { useServerStore } from '../stores/server.ts';
|
||||
import { api } from '../lib/api.ts';
|
||||
import { VoiceControls } from './VoiceControls.tsx';
|
||||
import { VideoGrid } from './VideoGrid.tsx';
|
||||
import { AudioRenderers } from './AudioRenderers.tsx';
|
||||
@@ -15,6 +17,18 @@ export function VoicePanel() {
|
||||
const dismissWhisper = useVoiceStore((state) => state.dismissWhisper);
|
||||
const currentUserId = useAuthStore((state) => state.user?.id);
|
||||
|
||||
const userPermissions = useServerStore((state) => state.userPermissions);
|
||||
const canMute = userPermissions.mute_members || userPermissions.administrator;
|
||||
|
||||
const handleMute = async (identity: string) => {
|
||||
if (!currentRoom) return;
|
||||
try {
|
||||
await api.post(`/voice/rooms/${currentRoom}/participants/${identity}/mute`);
|
||||
} catch (err) {
|
||||
console.error('Failed to mute participant', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentRoom) return null;
|
||||
|
||||
return (
|
||||
@@ -108,13 +122,24 @@ export function VoicePanel() {
|
||||
<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 className="ml-auto flex items-center gap-1">
|
||||
{canMute && !p.isMuted && (
|
||||
<button
|
||||
className="text-xxs text-gb-fg-f hover:text-gb-red"
|
||||
onClick={() => handleMute(p.identity)}
|
||||
title={`Mute ${p.username}`}
|
||||
>
|
||||
[mute]
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user