feat: implement CORS proxy for Matrix homeservers during development and refactor call handling logic
This commit is contained in:
@@ -328,6 +328,7 @@ export class CallService {
|
||||
// Step 2: Try fetching LiveKit JWT from homeservers in priority order
|
||||
const result = await fetchLiveKitJWTFromServers(
|
||||
homeserverPriority,
|
||||
this.config.homeserverBaseUrl,
|
||||
roomId,
|
||||
this.config.userId,
|
||||
this.config.deviceId,
|
||||
|
||||
@@ -152,7 +152,8 @@ export interface CallEvent {
|
||||
* Call service configuration
|
||||
*/
|
||||
export interface CallServiceConfig {
|
||||
livekitServiceUrl: string;
|
||||
/** @deprecated No longer used - JWT fetched dynamically from multiple homeservers */
|
||||
livekitServiceUrl?: string;
|
||||
homeserverBaseUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
|
||||
@@ -82,26 +82,12 @@ export function useCallService(): CallContextValue {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get LiveKit service URL from Matrix homeserver well-known
|
||||
const wellKnown = await fetchWellKnownWithRTC(baseUrl);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Call service init - well-known:', wellKnown);
|
||||
const livekitFocus = getLiveKitFocus(wellKnown);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Call service init - LiveKit focus:', livekitFocus);
|
||||
const livekitServiceUrl = livekitFocus?.livekit_service_url;
|
||||
|
||||
if (!livekitServiceUrl) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Call service init - No LiveKit config in well-known, calls not supported');
|
||||
setCallSupported(false);
|
||||
setCallSupportLoading(false);
|
||||
return;
|
||||
}
|
||||
// Initialize CallService without checking user's homeserver
|
||||
// Call support will be determined per-room based on available homeservers
|
||||
console.log('Call service init - Initializing (call support determined per-room)');
|
||||
|
||||
service = new CallService(
|
||||
{
|
||||
livekitServiceUrl,
|
||||
homeserverBaseUrl: baseUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
@@ -112,10 +98,10 @@ export function useCallService(): CallContextValue {
|
||||
|
||||
setCallService(service);
|
||||
setGlobalCallService(service); // Make available globally for Paarrot API
|
||||
setCallSupported(true);
|
||||
setCallSupported(true); // Global flag - actual support checked per-room
|
||||
setCallSupportLoading(false);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Call service init - Success! Calls are supported');
|
||||
console.log('Call service init - Success!');
|
||||
|
||||
unsubscribe = service.on((event: CallEvent) => {
|
||||
// Update state for any event that might change the call state
|
||||
@@ -245,8 +231,62 @@ export function useCallService(): CallContextValue {
|
||||
* Hook to check if a call is active in a specific room
|
||||
*/
|
||||
export function useRoomCall(roomId: string) {
|
||||
const { activeCall, startCall, endCall, callSupported, callSupportLoading, callService } = useCall();
|
||||
const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall();
|
||||
const mx = useMatrixClient();
|
||||
const [roomCallSupported, setRoomCallSupported] = useState(false);
|
||||
const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(true);
|
||||
|
||||
// Check if calls are supported for this specific room by checking all priority homeservers
|
||||
useEffect(() => {
|
||||
const checkRoomCallSupport = async () => {
|
||||
setRoomCallSupportLoading(true);
|
||||
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) {
|
||||
setRoomCallSupported(false);
|
||||
setRoomCallSupportLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get homeserver priority list for this room
|
||||
const homeserverPriority = getLiveKitHomeserverPriority(
|
||||
room,
|
||||
mx.getHomeserverUrl(),
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
|
||||
console.log(`Checking call support for room ${roomId}, homeserver priority:`, homeserverPriority);
|
||||
|
||||
// Check each homeserver in priority order
|
||||
for (const homeserver of homeserverPriority) {
|
||||
try {
|
||||
console.log(`Fetching well-known from ${homeserver}...`);
|
||||
const wellKnown = await fetchWellKnownWithRTC(homeserver);
|
||||
console.log(`Well-known response from ${homeserver}:`, wellKnown);
|
||||
|
||||
const livekitFocus = getLiveKitFocus(wellKnown);
|
||||
console.log(`LiveKit focus from ${homeserver}:`, livekitFocus);
|
||||
|
||||
if (livekitFocus) {
|
||||
console.log(`Call support found for room ${roomId} on homeserver: ${homeserver}`);
|
||||
setRoomCallSupported(true);
|
||||
setRoomCallSupportLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to check call support on ${homeserver}:`, error);
|
||||
// Continue to next homeserver
|
||||
}
|
||||
}
|
||||
|
||||
// No homeserver supports calls
|
||||
console.log(`No call support found for room ${roomId}`);
|
||||
setRoomCallSupported(false);
|
||||
setRoomCallSupportLoading(false);
|
||||
};
|
||||
|
||||
checkRoomCallSupport();
|
||||
}, [roomId, mx]);
|
||||
|
||||
const isInCall = activeCall?.roomId === roomId;
|
||||
const callState = isInCall ? activeCall.state : CallState.Idle;
|
||||
@@ -287,7 +327,7 @@ export function useRoomCall(roomId: string) {
|
||||
startVoiceCall,
|
||||
startVideoCall,
|
||||
endCall,
|
||||
callSupported,
|
||||
callSupportLoading,
|
||||
callSupported: roomCallSupported,
|
||||
callSupportLoading: roomCallSupportLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import { getActiveCallMembers } from './useRoomCallMembers';
|
||||
* Get room server from room ID (format: !roomid:server.com)
|
||||
*/
|
||||
function getRoomServer(roomId: string): string | undefined {
|
||||
return getMxIdServer(roomId);
|
||||
const server = getMxIdServer(roomId);
|
||||
console.log(`[getRoomServer] Room ID: ${roomId} -> Server: ${server}`);
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,13 +32,20 @@ export function getLiveKitHomeserverPriority(
|
||||
const memberCount = room.getMembers().length;
|
||||
const isDM = memberCount <= 2;
|
||||
|
||||
console.log(`[getLiveKitHomeserverPriority] Room: ${room.roomId}`);
|
||||
console.log(`[getLiveKitHomeserverPriority] Member count: ${memberCount}, isDM: ${isDM}`);
|
||||
console.log(`[getLiveKitHomeserverPriority] User homeserver: ${userHomeserver}`);
|
||||
|
||||
// Check for active call members
|
||||
const activeCallMembers = getActiveCallMembers(room);
|
||||
const hasActiveCall = activeCallMembers.length > 0;
|
||||
|
||||
console.log(`[getLiveKitHomeserverPriority] Active call members: ${activeCallMembers.length}, hasActiveCall: ${hasActiveCall}`);
|
||||
|
||||
if (hasActiveCall) {
|
||||
// Joining existing call - prioritize room's homeserver (where call is hosted)
|
||||
const roomServer = getRoomServer(room.roomId);
|
||||
console.log(`[getLiveKitHomeserverPriority] Room server (active call): ${roomServer}`);
|
||||
if (roomServer) {
|
||||
servers.push(`https://${roomServer}`);
|
||||
}
|
||||
@@ -60,32 +69,39 @@ export function getLiveKitHomeserverPriority(
|
||||
} else {
|
||||
// Starting new call - use original priority logic
|
||||
if (isDM) {
|
||||
console.log(`[getLiveKitHomeserverPriority] DM path - user homeserver first`);
|
||||
// For DMs: user's homeserver first, then other member's homeserver
|
||||
servers.push(userHomeserver);
|
||||
|
||||
const otherUserId = guessDmRoomUserId(room, myUserId);
|
||||
console.log(`[getLiveKitHomeserverPriority] Other user ID: ${otherUserId}`);
|
||||
if (otherUserId && otherUserId !== myUserId) {
|
||||
const otherServer = getMxIdServer(otherUserId);
|
||||
console.log(`[getLiveKitHomeserverPriority] Other user server: ${otherServer}`);
|
||||
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
|
||||
if (otherServer && otherServer !== userDomain) {
|
||||
servers.push(`https://${otherServer}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[getLiveKitHomeserverPriority] Channel path - room homeserver first`);
|
||||
// For channels: room's homeserver first, then user's homeserver
|
||||
const roomServer = getRoomServer(room.roomId);
|
||||
console.log(`[getLiveKitHomeserverPriority] Room server from ID: ${roomServer}`);
|
||||
if (roomServer) {
|
||||
servers.push(`https://${roomServer}`);
|
||||
}
|
||||
|
||||
// Fallback to user's homeserver if different
|
||||
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
|
||||
console.log(`[getLiveKitHomeserverPriority] User domain: ${userDomain}, room server: ${roomServer}`);
|
||||
if (!roomServer || roomServer !== userDomain) {
|
||||
servers.push(userHomeserver);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[getLiveKitHomeserverPriority] Final server list:`, servers);
|
||||
return servers;
|
||||
}
|
||||
|
||||
@@ -96,11 +112,16 @@ export function getLiveKitHomeserverPriority(
|
||||
*/
|
||||
export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined {
|
||||
const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci'];
|
||||
console.log(`[getLiveKitFocus] rtcFoci from well-known:`, rtcFoci);
|
||||
|
||||
if (!rtcFoci || !Array.isArray(rtcFoci)) {
|
||||
console.log(`[getLiveKitFocus] No rtc_foci found or not an array`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit');
|
||||
const livekitFocus = rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit');
|
||||
console.log(`[getLiveKitFocus] Found LiveKit focus:`, livekitFocus);
|
||||
return livekitFocus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,14 +130,28 @@ export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | und
|
||||
* @returns The well-known response with RTC configuration
|
||||
*/
|
||||
export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> {
|
||||
const wellKnownUrl = `${baseUrl}/.well-known/matrix/client`;
|
||||
// In development mode (localhost), use CORS proxy to avoid CORS issues
|
||||
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
|
||||
let wellKnownUrl: string;
|
||||
if (isDev) {
|
||||
// Extract hostname from baseUrl for proxy
|
||||
const hostname = baseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, '');
|
||||
wellKnownUrl = `/_matrix-cors-proxy/${hostname}/.well-known/matrix/client`;
|
||||
} else {
|
||||
wellKnownUrl = `${baseUrl}/.well-known/matrix/client`;
|
||||
}
|
||||
|
||||
console.log(`[fetchWellKnownWithRTC] Fetching from: ${wellKnownUrl}`);
|
||||
|
||||
const response = await fetch(wellKnownUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch well-known: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const data = await response.json();
|
||||
console.log(`[fetchWellKnownWithRTC] Response data:`, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +167,16 @@ export async function requestOpenIdToken(
|
||||
userId: string,
|
||||
accessToken: string
|
||||
): Promise<OpenIdToken> {
|
||||
const url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
|
||||
// In development mode (localhost), use CORS proxy to avoid CORS issues
|
||||
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
|
||||
let url: string;
|
||||
if (isDev) {
|
||||
const hostname = homeserverBaseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, '');
|
||||
url = `/_matrix-cors-proxy/${hostname}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
|
||||
} else {
|
||||
url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -175,7 +219,21 @@ export async function fetchLiveKitJWT(
|
||||
// to create the LiveKit room name.
|
||||
const slotId = 'm.call';
|
||||
|
||||
const response = await fetch(livekitServiceUrl, {
|
||||
// In development mode (localhost), use CORS proxy to avoid CORS issues
|
||||
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
|
||||
let url: string;
|
||||
if (isDev) {
|
||||
// Extract hostname and path from livekitServiceUrl
|
||||
const urlObj = new URL(livekitServiceUrl);
|
||||
const hostname = urlObj.hostname;
|
||||
const pathname = urlObj.pathname;
|
||||
url = `/_matrix-cors-proxy/${hostname}${pathname}`;
|
||||
} else {
|
||||
url = livekitServiceUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -202,7 +260,8 @@ export async function fetchLiveKitJWT(
|
||||
|
||||
/**
|
||||
* Try fetching LiveKit JWT from multiple homeservers in priority order
|
||||
* @param homeservers - Array of homeserver base URLs to try
|
||||
* @param homeservers - Array of homeserver base URLs to check for LiveKit config
|
||||
* @param userHomeserver - The user's own homeserver URL (for OpenID token)
|
||||
* @param roomId - The Matrix room ID
|
||||
* @param userId - The Matrix user ID
|
||||
* @param deviceId - The Matrix device ID
|
||||
@@ -211,6 +270,7 @@ export async function fetchLiveKitJWT(
|
||||
*/
|
||||
export async function fetchLiveKitJWTFromServers(
|
||||
homeservers: string[],
|
||||
userHomeserver: string,
|
||||
roomId: string,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
@@ -229,8 +289,9 @@ export async function fetchLiveKitJWTFromServers(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get OpenID token from this homeserver
|
||||
const openIdToken = await requestOpenIdToken(homeserver, userId, accessToken);
|
||||
// Always get OpenID token from user's own homeserver
|
||||
// (Can't use access token on remote homeserver)
|
||||
const openIdToken = await requestOpenIdToken(userHomeserver, userId, accessToken);
|
||||
|
||||
// Exchange for LiveKit JWT
|
||||
const jwt = await fetchLiveKitJWT(
|
||||
|
||||
@@ -56,7 +56,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { CallParticipantsIndicator } from './CallParticipantsIndicator';
|
||||
import { useCall } from '../call/useCall';
|
||||
import { useCall, useRoomCall } from '../call/useCall';
|
||||
import { CallType } from '../call/types';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
@@ -75,7 +75,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const { startCall, endCall, activeCall, callSupported } = useCall();
|
||||
const { startCall: startCallGlobal, endCall, activeCall } = useCall();
|
||||
const { callSupported } = useRoomCall(room.roomId);
|
||||
const markAsRead = useMarkAsRead(mx);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
@@ -119,7 +120,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
if (activeCall) {
|
||||
await endCall();
|
||||
}
|
||||
await startCall(room.roomId, CallType.Voice);
|
||||
await startCallGlobal(room.roomId, CallType.Voice);
|
||||
requestClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to join voice call:', error);
|
||||
|
||||
@@ -450,12 +450,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
|
||||
{isConnecting ? (
|
||||
<Spinner size="100" variant="Secondary" />
|
||||
) : (
|
||||
<Box direction="Row" gap="100" alignItems="Center">
|
||||
<Icon size="400" src={Icons.Phone} filled={isActive && activeCall?.callType === CallType.Voice} />
|
||||
{isUsingRemoteHomeserver && (
|
||||
<Icon size="50" src={Icons.HashGlobe} style={{ opacity: 0.7 }} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -481,12 +476,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
|
||||
{isConnecting ? (
|
||||
<Spinner size="100" variant="Secondary" />
|
||||
) : (
|
||||
<Box direction="Row" gap="100" alignItems="Center">
|
||||
<Icon size="400" src={Icons.VideoCamera} filled={isActive && activeCall?.callType === CallType.Video} />
|
||||
{isUsingRemoteHomeserver && (
|
||||
<Icon size="50" src={Icons.HashGlobe} style={{ opacity: 0.7 }} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;
|
||||
|
||||
export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);
|
||||
|
||||
const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])([^\s:]+):(\S+)$/);
|
||||
const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])([^\s:]+):(\S+)$/);
|
||||
|
||||
const validMxId = (id: string): boolean => !!matchMxId(id);
|
||||
|
||||
|
||||
@@ -135,6 +135,83 @@ function servePublicAssets() {
|
||||
};
|
||||
}
|
||||
|
||||
// CORS proxy for Matrix homeservers during development
|
||||
function corsProxyMiddleware() {
|
||||
return {
|
||||
name: 'vite-plugin-cors-proxy',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
// Proxy /.well-known requests: /_matrix-cors-proxy/homeserver.com/.well-known/matrix/client
|
||||
const corsProxyMatch = req.url?.match(/^\/_matrix-cors-proxy\/([^/]+)(\/.*)/);
|
||||
if (corsProxyMatch) {
|
||||
const [, hostname, pathname] = corsProxyMatch;
|
||||
const targetUrl = `https://${hostname}${pathname}`;
|
||||
|
||||
console.log(`[CORS Proxy] ${req.method} ${req.url}`);
|
||||
console.log(`[CORS Proxy] Target: ${targetUrl}`);
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Collect request body for POST/PUT
|
||||
let body = null;
|
||||
if (req.method === 'POST' || req.method === 'PUT') {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
body = Buffer.concat(chunks).toString();
|
||||
console.log(`[CORS Proxy] Request body:`, body);
|
||||
}
|
||||
|
||||
// Forward request with method, headers, and body
|
||||
const fetchOptions = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
'Content-Type': req.headers['content-type'] || 'application/json',
|
||||
'Authorization': req.headers['authorization'] || '',
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
fetchOptions.body = body;
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions);
|
||||
const data = await response.text();
|
||||
|
||||
console.log(`[CORS Proxy] Response status: ${response.status}`);
|
||||
console.log(`[CORS Proxy] Response data:`, data.substring(0, 500));
|
||||
|
||||
// Set CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Content-Type', response.headers.get('content-type') || 'application/json');
|
||||
|
||||
res.writeHead(response.status);
|
||||
res.end(data);
|
||||
} catch (error) {
|
||||
console.error(`[CORS Proxy] Error for ${targetUrl}:`, error);
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
appType: 'spa',
|
||||
publicDir: false,
|
||||
@@ -143,12 +220,14 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 38347,
|
||||
host: '0.0.0.0',
|
||||
cors: true, // Enable CORS for dev server
|
||||
fs: {
|
||||
// Allow serving files from one level up to the project root
|
||||
allow: ['..'],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
corsProxyMiddleware(), // Add CORS proxy for development
|
||||
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
|
||||
serveGifWorker(),
|
||||
servePublicAssets(),
|
||||
|
||||
Reference in New Issue
Block a user