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
|
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 {
|
func boolPtr(b bool) *bool {
|
||||||
return &b
|
return &b
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/livekit/protocol/livekit"
|
"github.com/livekit/protocol/livekit"
|
||||||
)
|
)
|
||||||
@@ -15,6 +16,7 @@ type Handler struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
client *Client
|
client *Client
|
||||||
hub *gateway.Hub
|
hub *gateway.Hub
|
||||||
|
checker *permissions.Checker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler {
|
func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler {
|
||||||
@@ -22,12 +24,15 @@ func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler {
|
|||||||
db: db,
|
db: db,
|
||||||
client: client,
|
client: client,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
|
checker: permissions.NewChecker(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth)
|
||||||
r.Post("/token", h.GetToken)
|
r.Post("/token", h.GetToken)
|
||||||
r.Get("/rooms/{roomID}/participants", h.GetParticipants)
|
r.Get("/rooms/{roomID}/participants", h.GetParticipants)
|
||||||
|
r.Post("/rooms/{roomID}/participants/{userID}/mute", h.MuteParticipant)
|
||||||
}
|
}
|
||||||
|
|
||||||
type tokenRequest struct {
|
type tokenRequest struct {
|
||||||
@@ -148,3 +153,53 @@ func (h *Handler) GetParticipants(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(result)
|
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 { useVoiceStore } from '../stores/voice.ts';
|
||||||
import { useAuthStore } from '../stores/auth.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 { VoiceControls } from './VoiceControls.tsx';
|
||||||
import { VideoGrid } from './VideoGrid.tsx';
|
import { VideoGrid } from './VideoGrid.tsx';
|
||||||
import { AudioRenderers } from './AudioRenderers.tsx';
|
import { AudioRenderers } from './AudioRenderers.tsx';
|
||||||
@@ -15,6 +17,18 @@ export function VoicePanel() {
|
|||||||
const dismissWhisper = useVoiceStore((state) => state.dismissWhisper);
|
const dismissWhisper = useVoiceStore((state) => state.dismissWhisper);
|
||||||
const currentUserId = useAuthStore((state) => state.user?.id);
|
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;
|
if (!currentRoom) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,13 +122,24 @@ export function VoicePanel() {
|
|||||||
<span className="text-xxs text-gb-green">[speaking]</span>
|
<span className="text-xxs text-gb-green">[speaking]</span>
|
||||||
)}
|
)}
|
||||||
{!isMe && (
|
{!isMe && (
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
{canMute && !p.isMuted && (
|
||||||
<button
|
<button
|
||||||
className="ml-auto text-xxs text-gb-fg-f hover:text-gb-orange"
|
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)}
|
onClick={() => whisperTo(p.identity, p.username)}
|
||||||
title={`Whisper to ${p.username}`}
|
title={`Whisper to ${p.username}`}
|
||||||
>
|
>
|
||||||
[whisper]
|
[whisper]
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user