feat(webrtc): add admin option to mute other participants

This commit is contained in:
2026-07-06 13:04:39 +00:00
parent 9aeedb1a53
commit ed408c4044
3 changed files with 120 additions and 13 deletions
+27
View File
@@ -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
}
+61 -6
View File
@@ -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"})
}
+32 -7
View File
@@ -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>
);