работаем бля работаем
This commit is contained in:
Binary file not shown.
54
client/.zed/settings.json
Normal file
54
client/.zed/settings.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
// Use ESLint's --fix:
|
||||
"code_actions_on_format": {
|
||||
"source.fixAll.eslint": true,
|
||||
},
|
||||
"formatter": [],
|
||||
// Enable eslint for all supported languages
|
||||
// Defaults only include https://github.com/search?q=repo%3Azed-industries%2Fzed%20eslint_languages&type=code
|
||||
"languages": {
|
||||
"HTML": {
|
||||
"language_servers": ["...", "eslint"],
|
||||
},
|
||||
"Markdown": {
|
||||
"language_servers": ["...", "eslint"],
|
||||
},
|
||||
"Markdown-Inline": {
|
||||
"language_servers": ["...", "eslint"],
|
||||
},
|
||||
"JSON": {
|
||||
"language_servers": ["...", "eslint"],
|
||||
},
|
||||
"JSONC": {
|
||||
"language_servers": ["...", "eslint"],
|
||||
},
|
||||
"YAML": {
|
||||
"language_servers": ["...", "eslint"],
|
||||
},
|
||||
"CSS": {
|
||||
"language_servers": ["...", "eslint"],
|
||||
},
|
||||
// Add other languages as needed
|
||||
},
|
||||
"lsp": {
|
||||
"eslint": {
|
||||
"settings": {
|
||||
"workingDirectories": ["./"],
|
||||
|
||||
// Silent the stylistic rules in your IDE, but still auto fix them
|
||||
"rulesCustomizations": [
|
||||
{ "rule": "style/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "format/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-indent", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spacing", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spaces", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-order", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-dangle", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-newline", "severity": "off", "fixable": true },
|
||||
{ "rule": "*quotes", "severity": "off", "fixable": true },
|
||||
{ "rule": "*semi", "severity": "off", "fixable": true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
</PrimeAvatar>
|
||||
|
||||
<p class="flex-1 text-sm leading-5 font-medium text-color truncate w-0">
|
||||
{{ client.displayName || client.username }}
|
||||
{{ client.displayName || client.username || client.socketId }}
|
||||
</p>
|
||||
|
||||
<Component :is="expanded ? ChevronUp : ChevronDown" v-if="!isMe" :size="20" class="text-muted-color" />
|
||||
|
||||
@@ -85,7 +85,7 @@ export const useApp = createGlobalState(() => {
|
||||
|
||||
await muteInput()
|
||||
|
||||
await signaling.socket.value?.emitWithAck('updateClient', {
|
||||
await signaling.socket.value?.emitWithAck('update-client', {
|
||||
outputMuted: true,
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ export const useApp = createGlobalState(() => {
|
||||
if (!previousInputMuted.value)
|
||||
await unmuteInput()
|
||||
|
||||
await signaling.socket.value?.emitWithAck('updateClient', {
|
||||
await signaling.socket.value?.emitWithAck('update-client', {
|
||||
outputMuted: false,
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export const useClients = createGlobalState(() => {
|
||||
if (!socket)
|
||||
return
|
||||
|
||||
socket.on('clientChanged', (clientId: ChadClient['socketId'], updatedClient: UpdatedClient) => {
|
||||
socket.on('client-updated', (clientId: ChadClient['socketId'], updatedClient: UpdatedClient) => {
|
||||
const client = getClient(clientId)
|
||||
|
||||
if (!client)
|
||||
|
||||
@@ -26,15 +26,15 @@ const ICE_SERVERS: RTCIceServer[] = [
|
||||
]
|
||||
|
||||
export const useMediasoup = createSharedComposable(() => {
|
||||
const { emit } = useEventBus()
|
||||
const eventBus = useEventBus()
|
||||
|
||||
const signaling = useSignaling()
|
||||
const { addClient, removeClient, me } = useClients()
|
||||
const { addClient, removeClient, me, clients, updateClient } = useClients()
|
||||
const preferences = usePreferences()
|
||||
const { getShareStream } = useDevices()
|
||||
|
||||
const device = shallowRef<mediasoupClient.Device>()
|
||||
const rtpCapabilities = shallowRef<mediasoupClient.types.RtpCapabilities>()
|
||||
const routerRtpCapabilities = shallowRef<mediasoupClient.types.RtpCapabilities>()
|
||||
const sendTransport = shallowRef<mediasoupClient.types.Transport>()
|
||||
const recvTransport = shallowRef<mediasoupClient.types.Transport>()
|
||||
|
||||
@@ -79,18 +79,30 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
if (!socket)
|
||||
return
|
||||
|
||||
socket.on('authenticated', async () => {
|
||||
socket.on('new-client', (client) => {
|
||||
addClient(client)
|
||||
|
||||
eventBus.emit('client:added', client)
|
||||
})
|
||||
|
||||
socket.on('client-switched-channel', (client) => {
|
||||
updateClient(client.socketId, client)
|
||||
})
|
||||
|
||||
socket.on('initialized', async (initData) => {
|
||||
if (!signaling.socket.value)
|
||||
return
|
||||
|
||||
device.value = new mediasoupClient.Device()
|
||||
rtpCapabilities.value = await signaling.socket.value.emitWithAck('getRtpCapabilities')
|
||||
routerRtpCapabilities.value = initData.rtpCapabilities
|
||||
|
||||
await device.value.load({ routerRtpCapabilities: rtpCapabilities.value! })
|
||||
clients.value = initData.clients
|
||||
|
||||
await device.value.load({ routerRtpCapabilities: routerRtpCapabilities.value! })
|
||||
|
||||
// Send transport
|
||||
{
|
||||
const transportInfo = await signaling.socket.value.emitWithAck('createTransport', { producing: true, consuming: false })
|
||||
const transportInfo = await signaling.socket.value.emitWithAck('create-transport', { producing: true, consuming: false })
|
||||
sendTransport.value = device.value.createSendTransport({
|
||||
...transportInfo,
|
||||
iceServers: [
|
||||
@@ -101,7 +113,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
|
||||
sendTransport.value.on('connect', async ({ dtlsParameters }, callback, errback) => {
|
||||
try {
|
||||
await signaling.socket.value!.emitWithAck('connectTransport', {
|
||||
await signaling.socket.value!.emitWithAck('connect-transport', {
|
||||
transportId: sendTransport.value!.id,
|
||||
dtlsParameters,
|
||||
})
|
||||
@@ -135,7 +147,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
|
||||
// Recv Transport
|
||||
{
|
||||
const transportInfo = await signaling.socket.value.emitWithAck('createTransport', { producing: false, consuming: true })
|
||||
const transportInfo = await signaling.socket.value.emitWithAck('create-transport', { producing: false, consuming: true })
|
||||
recvTransport.value = device.value.createRecvTransport({
|
||||
...transportInfo,
|
||||
iceServers: [
|
||||
@@ -146,7 +158,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
|
||||
recvTransport.value.on('connect', async ({ dtlsParameters }, callback, errback) => {
|
||||
try {
|
||||
await signaling.socket.value!.emitWithAck('connectTransport', {
|
||||
await signaling.socket.value!.emitWithAck('connect-transport', {
|
||||
transportId: recvTransport.value!.id,
|
||||
dtlsParameters,
|
||||
})
|
||||
@@ -160,36 +172,33 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
}
|
||||
})
|
||||
}
|
||||
//
|
||||
// const joinedClients = (await signaling.socket.value.emitWithAck('join', {
|
||||
// rtpCapabilities: routerRtpCapabilities.value,
|
||||
// }))
|
||||
//
|
||||
// addClient(...joinedClients)
|
||||
//
|
||||
// if (me.value)
|
||||
// eventBus.emit('socket:authenticated', { socketId: me.value.socketId })
|
||||
//
|
||||
|
||||
const joinedClients = (await signaling.socket.value.emitWithAck('join', {
|
||||
rtpCapabilities: rtpCapabilities.value,
|
||||
}))
|
||||
|
||||
addClient(...joinedClients)
|
||||
|
||||
if (me.value)
|
||||
emit('socket:authenticated', { socketId: me.value.socketId })
|
||||
|
||||
// TODO: при переподключении проверять inputMuted
|
||||
await enableMic()
|
||||
})
|
||||
|
||||
socket.on('newPeer', (client) => {
|
||||
addClient(client)
|
||||
emit('client:added', client)
|
||||
})
|
||||
|
||||
socket.on('peerClosed', (id) => {
|
||||
socket.on('client-disconnected', (id) => {
|
||||
const { getClient } = useClients()
|
||||
const client = getClient(id)
|
||||
|
||||
removeClient(id)
|
||||
|
||||
if (client)
|
||||
emit('client:removed', client)
|
||||
eventBus.emit('client:removed', client)
|
||||
})
|
||||
|
||||
socket.on(
|
||||
'newConsumer',
|
||||
'new-consumer',
|
||||
async (
|
||||
{ id, producerId, kind, rtpParameters, socketId, appData, producerPaused },
|
||||
cb,
|
||||
@@ -216,18 +225,18 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
raw: markRaw(consumer),
|
||||
}
|
||||
|
||||
emit('consumer:added', consumers.value[consumer.id]!)
|
||||
eventBus.emit('consumer:added', consumers.value[consumer.id]!)
|
||||
|
||||
consumer.observer.on('resume', () => {
|
||||
consumers.value[consumer.id]!.paused = false
|
||||
|
||||
emit('consumer:resumed', consumers.value[consumer.id]!)
|
||||
eventBus.emit('consumer:resumed', consumers.value[consumer.id]!)
|
||||
})
|
||||
|
||||
consumer.observer.on('pause', () => {
|
||||
consumers.value[consumer.id]!.paused = true
|
||||
|
||||
emit('consumer:paused', consumers.value[consumer.id]!)
|
||||
eventBus.emit('consumer:paused', consumers.value[consumer.id]!)
|
||||
})
|
||||
|
||||
consumer.observer.on('close', () => {
|
||||
@@ -236,7 +245,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
delete consumers.value[consumer.id]
|
||||
|
||||
if (consumerData)
|
||||
emit('consumer:removed', consumerData)
|
||||
eventBus.emit('consumer:removed', consumerData)
|
||||
})
|
||||
|
||||
consumer.on('trackended', () => {
|
||||
@@ -248,7 +257,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
)
|
||||
|
||||
socket.on(
|
||||
'consumerClosed',
|
||||
'consumer-closed',
|
||||
async (
|
||||
{ consumerId },
|
||||
) => {
|
||||
@@ -261,7 +270,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
},
|
||||
)
|
||||
|
||||
socket.on('consumerPaused', ({ consumerId }) => {
|
||||
socket.on('consumer-paused', ({ consumerId }) => {
|
||||
const consumer = consumers.value[consumerId]
|
||||
|
||||
if (!consumer)
|
||||
@@ -270,7 +279,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
consumer.raw.pause()
|
||||
})
|
||||
|
||||
socket.on('consumerResumed', ({ consumerId }) => {
|
||||
socket.on('consumer-resumed', ({ consumerId }) => {
|
||||
const consumer = consumers.value[consumerId]
|
||||
|
||||
if (!consumer)
|
||||
@@ -279,13 +288,13 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
consumer.raw.resume()
|
||||
})
|
||||
|
||||
socket.on('speakingPeers', (value: SpeakingClient[]) => {
|
||||
socket.on('speaking-clients', (value: SpeakingClient[]) => {
|
||||
speakingClients.value = value
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
device.value = undefined
|
||||
rtpCapabilities.value = undefined
|
||||
routerRtpCapabilities.value = undefined
|
||||
|
||||
sendTransport.value?.close()
|
||||
sendTransport.value = undefined
|
||||
@@ -317,27 +326,28 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
raw: markRaw(producer),
|
||||
}
|
||||
|
||||
emit('producer:added', producers.value[producer.id]!)
|
||||
eventBus.emit('producer:added', producers.value[producer.id]!)
|
||||
|
||||
producer.observer.on('pause', () => {
|
||||
producers.value[producer.id]!.paused = true
|
||||
|
||||
emit('producer:paused', producers.value[producer.id]!)
|
||||
eventBus.emit('producer:paused', producers.value[producer.id]!)
|
||||
})
|
||||
|
||||
producer.observer.on('resume', () => {
|
||||
producers.value[producer.id]!.paused = false
|
||||
|
||||
emit('producer:resumed', producers.value[producer.id]!)
|
||||
eventBus.emit('producer:resumed', producers.value[producer.id]!)
|
||||
})
|
||||
|
||||
producer.observer.on('close', () => {
|
||||
console.log('producer closed')
|
||||
const producerData = producers.value[producer.id]
|
||||
|
||||
delete producers.value[producer.id]
|
||||
|
||||
if (producerData)
|
||||
emit('producer:removed', producerData)
|
||||
eventBus.emit('producer:removed', producerData)
|
||||
})
|
||||
|
||||
producer.on('trackended', () => {
|
||||
@@ -352,7 +362,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
try {
|
||||
producer.raw.close()
|
||||
|
||||
await signaling.socket.value.emitWithAck('closeProducer', {
|
||||
await signaling.socket.value.emitWithAck('close-producer', {
|
||||
producerId: producer.id,
|
||||
})
|
||||
}
|
||||
@@ -455,7 +465,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
await createProducer({
|
||||
track,
|
||||
streamId: 'share',
|
||||
codec: device.value.rtpCapabilities.codecs?.find(
|
||||
codec: device.value.sendRtpCapabilities.codecs?.find(
|
||||
c => c.mimeType.toLowerCase() === 'video/AV1',
|
||||
),
|
||||
codecOptions: {
|
||||
@@ -478,7 +488,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
try {
|
||||
producer.raw.pause()
|
||||
|
||||
await signaling.socket.value.emitWithAck('pauseProducer', {
|
||||
await signaling.socket.value.emitWithAck('pause-producer', {
|
||||
producerId: producer.id,
|
||||
})
|
||||
}
|
||||
@@ -494,7 +504,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
try {
|
||||
producer.raw.resume()
|
||||
|
||||
await signaling.socket.value.emitWithAck('resumeProducer', {
|
||||
await signaling.socket.value.emitWithAck('resume-producer', {
|
||||
producerId: producer.id,
|
||||
})
|
||||
}
|
||||
@@ -526,7 +536,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
speakingClients,
|
||||
sendTransport,
|
||||
recvTransport,
|
||||
rtpCapabilities,
|
||||
rtpCapabilities: routerRtpCapabilities,
|
||||
device,
|
||||
micProducer,
|
||||
videoProducer,
|
||||
@@ -536,5 +546,7 @@ export const useMediasoup = createSharedComposable(() => {
|
||||
enableVideo,
|
||||
enableShare,
|
||||
disableProducer,
|
||||
consumersArray,
|
||||
producersArray,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -52,11 +52,39 @@
|
||||
|
||||
<PrimeScrollPanel class="bg-surface-900 rounded-xl overflow-hidden" style="min-height: 0">
|
||||
<div v-auto-animate class="p-3 space-y-1">
|
||||
<ClientRow v-for="client of clients" :key="client.userId" :client="client" />
|
||||
<template v-for="channel in channels" :key="channel.id">
|
||||
<PrimeDivider>
|
||||
<PrimeButton size="small" variant="text" @click="joinChannel(channel)">
|
||||
{{ channel.name }}
|
||||
</PrimeButton>
|
||||
</PrimeDivider>
|
||||
<ClientRow v-for="client in clients.filter(_client => _client.channelId === channel.id)" :key="client.socketId" :client="client">
|
||||
{{ client.userId }}
|
||||
</ClientRow>
|
||||
</template>
|
||||
<!-- <ClientRow v-for="client of clients" :key="client.userId" :client="client" /> -->
|
||||
</div>
|
||||
</PrimeScrollPanel>
|
||||
|
||||
<div class="bg-surface-900 rounded-xl overflow-hidden p-3 flex flex-col min-h-full">
|
||||
<dl>
|
||||
<dt>Socket ID</dt>
|
||||
<dd>{{ socket?.id }}</dd>
|
||||
|
||||
<br>
|
||||
<dt>Producers</dt>
|
||||
<dd v-for="producer in producersArray" :key="producer.id">
|
||||
{{ producer.id }}
|
||||
{{ producer.appData }}
|
||||
</dd>
|
||||
|
||||
<br>
|
||||
<dl>Consumers</dl>
|
||||
<dd v-for="consumer in consumersArray" :key="consumer.id">
|
||||
{{ consumer.id }}
|
||||
{{ consumer.appData }}
|
||||
</dd>
|
||||
</dl>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,6 +93,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import chadApi from '#shared/chad-api'
|
||||
import {
|
||||
Camera,
|
||||
CameraOff,
|
||||
@@ -80,6 +109,13 @@ import {
|
||||
VolumeOff,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const channels = shallowRef<any[]>([])
|
||||
|
||||
;(async () => {
|
||||
channels.value = await chadApi<any[]>('/channels', { method: 'GET' })
|
||||
})()
|
||||
|
||||
const { me } = useClients()
|
||||
const {
|
||||
version,
|
||||
clients,
|
||||
@@ -93,7 +129,8 @@ const {
|
||||
toggleVideo,
|
||||
toggleShare,
|
||||
} = useApp()
|
||||
const { connect, connected } = useSignaling()
|
||||
const { connect, connected, socket } = useSignaling()
|
||||
const { consumersArray, producersArray } = useMediasoup()
|
||||
|
||||
interface Tab {
|
||||
id: string
|
||||
@@ -150,4 +187,30 @@ watch(activeTab, (activeTab) => {
|
||||
})
|
||||
|
||||
connect()
|
||||
|
||||
async function joinChannel(channel) {
|
||||
socket.value?.emit('join-channel', { channelId: channel.id })
|
||||
}
|
||||
|
||||
watch(socket, (socket) => {
|
||||
if (!socket)
|
||||
return
|
||||
|
||||
socket.on('channel-removed', (channelId) => {
|
||||
const idx = channels.value.findIndex(channel => channel.id === channelId)
|
||||
|
||||
if (idx === -1)
|
||||
return
|
||||
|
||||
channels.value.splice(idx, 1)
|
||||
|
||||
triggerRef(channels)
|
||||
})
|
||||
|
||||
socket.on('channel-created', (channel) => {
|
||||
channels.value.push(channel)
|
||||
|
||||
triggerRef(channels)
|
||||
})
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"linkify-string": "^4.3.2",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"mediasoup-client": "^3.18.6",
|
||||
"mediasoup-client": "^3.19.0",
|
||||
"mitt": "^3.0.1",
|
||||
"nuxt": "^4.2.2",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
@@ -3,8 +3,7 @@ import type { Consumer as MediasoupConsumer, Producer as MediasoupProducer } fro
|
||||
export interface ChadClient {
|
||||
socketId: string
|
||||
userId: string
|
||||
username: string
|
||||
displayName: string
|
||||
channelId: string
|
||||
inputMuted?: boolean
|
||||
outputMuted?: boolean
|
||||
|
||||
|
||||
@@ -3004,7 +3004,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.12":
|
||||
"@types/debug@npm:^4.0.0":
|
||||
version: 4.1.12
|
||||
resolution: "@types/debug@npm:4.1.12"
|
||||
dependencies:
|
||||
@@ -3013,6 +3013,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/debug@npm:^4.1.13":
|
||||
version: 4.1.13
|
||||
resolution: "@types/debug@npm:4.1.13"
|
||||
dependencies:
|
||||
"@types/ms": "npm:*"
|
||||
checksum: 10c0/e5e124021bbdb23a82727eee0a726ae0fc8a3ae1f57253cbcc47497f259afb357de7f6941375e773e1abbfa1604c1555b901a409d762ec2bb4c1612131d4afb7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree@npm:*, @types/estree@npm:1.0.8, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8":
|
||||
version: 1.0.8
|
||||
resolution: "@types/estree@npm:1.0.8"
|
||||
@@ -4082,7 +4091,7 @@ __metadata:
|
||||
linkify-string: "npm:^4.3.2"
|
||||
linkifyjs: "npm:^4.3.2"
|
||||
lucide-vue-next: "npm:^0.562.0"
|
||||
mediasoup-client: "npm:^3.18.6"
|
||||
mediasoup-client: "npm:^3.19.0"
|
||||
mitt: "npm:^3.0.1"
|
||||
nuxt: "npm:^4.2.2"
|
||||
postcss: "npm:^8.5.6"
|
||||
@@ -7355,11 +7364,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mediasoup-client@npm:^3.18.6":
|
||||
version: 3.18.6
|
||||
resolution: "mediasoup-client@npm:3.18.6"
|
||||
"mediasoup-client@npm:^3.19.0":
|
||||
version: 3.19.0
|
||||
resolution: "mediasoup-client@npm:3.19.0"
|
||||
dependencies:
|
||||
"@types/debug": "npm:^4.1.12"
|
||||
"@types/debug": "npm:^4.1.13"
|
||||
"@types/events-alias": "npm:@types/events@^3.0.3"
|
||||
awaitqueue: "npm:^3.3.0"
|
||||
debug: "npm:^4.4.3"
|
||||
@@ -7368,7 +7377,7 @@ __metadata:
|
||||
h264-profile-level-id: "npm:^2.3.2"
|
||||
sdp-transform: "npm:^3.0.0"
|
||||
supports-color: "npm:^10.2.2"
|
||||
checksum: 10c0/f5baff9139afccf88de5db767c1139efa5cdd68f4871e2fa9d6ff94d2e71d2365dc40e9ba6e903cde5fbb51a2d82972e738da656be9f6fc7006640fdd82dd5da
|
||||
checksum: 10c0/9fde5ec5daec91d43a88796f49e2b1b7a018c8100a3f99786966678a0e0b5328e88f6e6af36d50f9eed93889b84f23a164865c7177c0767ee805c7a8c7a51eb2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user