index.js 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149
  1. import 'stream-browserify' // see https://github.com/ericgundrum/pouch-websocket-sync-example/commit/2a4437b013092cc7b2cd84cf1499172c84a963a3
  2. import 'subworkers' // polyfill for https://bugs.chromium.org/p/chromium/issues/detail?id=31666
  3. import url from 'url'
  4. import ByteBuffer from 'bytebuffer'
  5. import MumbleClient from 'mumble-client'
  6. import WorkerBasedMumbleConnector from './worker-client'
  7. import BufferQueueNode from 'web-audio-buffer-queue'
  8. import audioContext from 'audio-context'
  9. import ko from 'knockout'
  10. import _dompurify from 'dompurify'
  11. import keyboardjs from 'keyboardjs'
  12. import { ContinuousVoiceHandler, PushToTalkVoiceHandler, VADVoiceHandler, initVoice } from './voice'
  13. import {initialize as localizationInitialize, translate} from './loc';
  14. const dompurify = _dompurify(window)
  15. function sanitize (html) {
  16. return dompurify.sanitize(html, {
  17. ALLOWED_TAGS: ['br', 'b', 'i', 'u', 'a', 'span', 'p']
  18. })
  19. }
  20. function openContextMenu (event, contextMenu, target) {
  21. contextMenu.posX(event.clientX)
  22. contextMenu.posY(event.clientY)
  23. contextMenu.target(target)
  24. const closeListener = (event) => {
  25. // Always close, no matter where they clicked
  26. setTimeout(() => { // delay to allow click to be actually processed
  27. contextMenu.target(null)
  28. unregister()
  29. })
  30. }
  31. const unregister = () => document.removeEventListener('click', closeListener)
  32. document.addEventListener('click', closeListener)
  33. event.stopPropagation()
  34. event.preventDefault()
  35. }
  36. // GUI
  37. function ContextMenu () {
  38. var self = this
  39. self.posX = ko.observable()
  40. self.posY = ko.observable()
  41. self.target = ko.observable()
  42. }
  43. function ConnectDialog () {
  44. var self = this
  45. self.address = ko.observable('')
  46. self.port = ko.observable('')
  47. self.tokenToAdd = ko.observable('')
  48. self.selectedTokens = ko.observableArray([])
  49. self.tokens = ko.observableArray([])
  50. self.username = ko.observable('')
  51. self.password = ko.observable('')
  52. self.channelName = ko.observable('')
  53. self.joinOnly = ko.observable(false)
  54. self.visible = ko.observable(true)
  55. self.show = self.visible.bind(self.visible, true)
  56. self.hide = self.visible.bind(self.visible, false)
  57. self.connect = function () {
  58. self.hide()
  59. ui.connect(self.username(), self.address(), self.port(), self.tokens(), self.password(), self.channelName())
  60. }
  61. self.addToken = function() {
  62. if ((self.tokenToAdd() != "") && (self.tokens.indexOf(self.tokenToAdd()) < 0)) {
  63. self.tokens.push(self.tokenToAdd())
  64. }
  65. self.tokenToAdd("")
  66. }
  67. self.removeSelectedTokens = function() {
  68. this.tokens.removeAll(this.selectedTokens())
  69. this.selectedTokens([])
  70. }
  71. }
  72. function ConnectErrorDialog (connectDialog) {
  73. var self = this
  74. self.type = ko.observable(0)
  75. self.reason = ko.observable('')
  76. self.username = connectDialog.username
  77. self.password = connectDialog.password
  78. self.joinOnly = connectDialog.joinOnly
  79. self.visible = ko.observable(false)
  80. self.show = self.visible.bind(self.visible, true)
  81. self.hide = self.visible.bind(self.visible, false)
  82. self.connect = () => {
  83. self.hide()
  84. connectDialog.connect()
  85. }
  86. }
  87. class ConnectionInfo {
  88. constructor (ui) {
  89. this._ui = ui
  90. this.visible = ko.observable(false)
  91. this.serverVersion = ko.observable()
  92. this.latencyMs = ko.observable(NaN)
  93. this.latencyDeviation = ko.observable(NaN)
  94. this.remoteHost = ko.observable()
  95. this.remotePort = ko.observable()
  96. this.maxBitrate = ko.observable(NaN)
  97. this.currentBitrate = ko.observable(NaN)
  98. this.maxBandwidth = ko.observable(NaN)
  99. this.currentBandwidth = ko.observable(NaN)
  100. this.codec = ko.observable()
  101. this.show = () => {
  102. if (!ui.thisUser()) return
  103. this.update()
  104. this.visible(true)
  105. }
  106. this.hide = () => this.visible(false)
  107. }
  108. update () {
  109. let client = this._ui.client
  110. this.serverVersion(client.serverVersion)
  111. let dataStats = client.dataStats
  112. if (dataStats) {
  113. this.latencyMs(dataStats.mean)
  114. this.latencyDeviation(Math.sqrt(dataStats.variance))
  115. }
  116. this.remoteHost(this._ui.remoteHost())
  117. this.remotePort(this._ui.remotePort())
  118. let spp = this._ui.settings.samplesPerPacket
  119. let maxBitrate = client.getMaxBitrate(spp, false)
  120. let maxBandwidth = client.maxBandwidth
  121. let actualBitrate = client.getActualBitrate(spp, false)
  122. let actualBandwidth = MumbleClient.calcEnforcableBandwidth(actualBitrate, spp, false)
  123. this.maxBitrate(maxBitrate)
  124. this.currentBitrate(actualBitrate)
  125. this.maxBandwidth(maxBandwidth)
  126. this.currentBandwidth(actualBandwidth)
  127. this.codec('Opus') // only one supported for sending
  128. }
  129. }
  130. function CommentDialog () {
  131. var self = this
  132. self.visible = ko.observable(false)
  133. self.show = function () {
  134. self.visible(true)
  135. }
  136. }
  137. class SettingsDialog {
  138. constructor (settings) {
  139. this.voiceMode = ko.observable(settings.voiceMode)
  140. this.pttKey = ko.observable(settings.pttKey)
  141. this.pttKeyDisplay = ko.observable(settings.pttKey)
  142. this.vadLevel = ko.observable(settings.vadLevel)
  143. this.testVadLevel = ko.observable(0)
  144. this.testVadActive = ko.observable(false)
  145. this.showAvatars = ko.observable(settings.showAvatars())
  146. this.userCountInChannelName = ko.observable(settings.userCountInChannelName())
  147. // Need to wrap this in a pureComputed to make sure it's always numeric
  148. let audioBitrate = ko.observable(settings.audioBitrate)
  149. this.audioBitrate = ko.pureComputed({
  150. read: audioBitrate,
  151. write: (value) => audioBitrate(Number(value))
  152. })
  153. this.samplesPerPacket = ko.observable(settings.samplesPerPacket)
  154. this.msPerPacket = ko.pureComputed({
  155. read: () => this.samplesPerPacket() / 48,
  156. write: (value) => this.samplesPerPacket(value * 48)
  157. })
  158. this._setupTestVad()
  159. this.vadLevel.subscribe(() => this._setupTestVad())
  160. }
  161. _setupTestVad () {
  162. if (this._testVad) {
  163. this._testVad.end()
  164. }
  165. let dummySettings = new Settings({})
  166. this.applyTo(dummySettings)
  167. this._testVad = new VADVoiceHandler(null, dummySettings)
  168. this._testVad.on('started_talking', () => this.testVadActive(true))
  169. .on('stopped_talking', () => this.testVadActive(false))
  170. .on('level', level => this.testVadLevel(level))
  171. testVoiceHandler = this._testVad
  172. }
  173. applyTo (settings) {
  174. settings.voiceMode = this.voiceMode()
  175. settings.pttKey = this.pttKey()
  176. settings.vadLevel = this.vadLevel()
  177. settings.showAvatars(this.showAvatars())
  178. settings.userCountInChannelName(this.userCountInChannelName())
  179. settings.audioBitrate = this.audioBitrate()
  180. settings.samplesPerPacket = this.samplesPerPacket()
  181. }
  182. end () {
  183. this._testVad.end()
  184. testVoiceHandler = null
  185. }
  186. recordPttKey () {
  187. var combo = []
  188. const keydown = e => {
  189. combo = e.pressedKeys
  190. let comboStr = combo.join(' + ')
  191. this.pttKeyDisplay('> ' + comboStr + ' <')
  192. }
  193. const keyup = () => {
  194. keyboardjs.unbind('', keydown, keyup)
  195. let comboStr = combo.join(' + ')
  196. if (comboStr) {
  197. this.pttKey(comboStr).pttKeyDisplay(comboStr)
  198. } else {
  199. this.pttKeyDisplay(this.pttKey())
  200. }
  201. }
  202. keyboardjs.bind('', keydown, keyup)
  203. this.pttKeyDisplay('> ? <')
  204. }
  205. totalBandwidth () {
  206. return MumbleClient.calcEnforcableBandwidth(
  207. this.audioBitrate(),
  208. this.samplesPerPacket(),
  209. true
  210. )
  211. }
  212. positionBandwidth () {
  213. return this.totalBandwidth() - MumbleClient.calcEnforcableBandwidth(
  214. this.audioBitrate(),
  215. this.samplesPerPacket(),
  216. false
  217. )
  218. }
  219. overheadBandwidth () {
  220. return MumbleClient.calcEnforcableBandwidth(
  221. 0,
  222. this.samplesPerPacket(),
  223. false
  224. )
  225. }
  226. }
  227. class Settings {
  228. constructor (defaults) {
  229. const load = key => window.localStorage.getItem('mumble.' + key)
  230. this.voiceMode = load('voiceMode') || defaults.voiceMode
  231. this.pttKey = load('pttKey') || defaults.pttKey
  232. this.vadLevel = load('vadLevel') || defaults.vadLevel
  233. this.toolbarVertical = load('toolbarVertical') || defaults.toolbarVertical
  234. this.showAvatars = ko.observable(load('showAvatars') || defaults.showAvatars)
  235. this.userCountInChannelName = ko.observable(load('userCountInChannelName') || defaults.userCountInChannelName)
  236. this.audioBitrate = Number(load('audioBitrate')) || defaults.audioBitrate
  237. this.samplesPerPacket = Number(load('samplesPerPacket')) || defaults.samplesPerPacket
  238. }
  239. save () {
  240. const save = (key, val) => window.localStorage.setItem('mumble.' + key, val)
  241. save('voiceMode', this.voiceMode)
  242. save('pttKey', this.pttKey)
  243. save('vadLevel', this.vadLevel)
  244. save('toolbarVertical', this.toolbarVertical)
  245. save('showAvatars', this.showAvatars())
  246. save('userCountInChannelName', this.userCountInChannelName())
  247. save('audioBitrate', this.audioBitrate)
  248. save('samplesPerPacket', this.samplesPerPacket)
  249. }
  250. }
  251. class GlobalBindings {
  252. constructor (config) {
  253. this.config = config
  254. this.settings = new Settings(config.settings)
  255. this.connector = new WorkerBasedMumbleConnector()
  256. this.client = null
  257. this.userContextMenu = new ContextMenu()
  258. this.channelContextMenu = new ContextMenu()
  259. this.connectDialog = new ConnectDialog()
  260. this.connectErrorDialog = new ConnectErrorDialog(this.connectDialog)
  261. this.connectionInfo = new ConnectionInfo(this)
  262. this.commentDialog = new CommentDialog()
  263. this.settingsDialog = ko.observable()
  264. this.minimalView = ko.observable(false)
  265. this.log = ko.observableArray()
  266. this.remoteHost = ko.observable()
  267. this.remotePort = ko.observable()
  268. this.thisUser = ko.observable()
  269. this.root = ko.observable()
  270. this.avatarView = ko.observable()
  271. this.messageBox = ko.observable('')
  272. this.toolbarHorizontal = ko.observable(!this.settings.toolbarVertical)
  273. this.selected = ko.observable()
  274. this.selfMute = ko.observable()
  275. this.selfDeaf = ko.observable()
  276. this.selfMute.subscribe(mute => {
  277. if (voiceHandler) {
  278. voiceHandler.setMute(mute)
  279. }
  280. })
  281. this.toggleToolbarOrientation = () => {
  282. this.toolbarHorizontal(!this.toolbarHorizontal())
  283. this.settings.toolbarVertical = !this.toolbarHorizontal()
  284. this.settings.save()
  285. }
  286. this.select = element => {
  287. this.selected(element)
  288. }
  289. this.openSettings = () => {
  290. this.settingsDialog(new SettingsDialog(this.settings))
  291. }
  292. this.applySettings = () => {
  293. const settingsDialog = this.settingsDialog()
  294. settingsDialog.applyTo(this.settings)
  295. this._updateVoiceHandler()
  296. this.settings.save()
  297. this.closeSettings()
  298. }
  299. this.closeSettings = () => {
  300. if (this.settingsDialog()) {
  301. this.settingsDialog().end()
  302. }
  303. this.settingsDialog(null)
  304. }
  305. this.getTimeString = () => {
  306. return '[' + new Date().toLocaleTimeString('en-US') + ']'
  307. }
  308. this.connect = (username, host, port, tokens = [], password, channelName = "") => {
  309. this.resetClient()
  310. this.remoteHost(host)
  311. this.remotePort(port)
  312. log('Connecting to server ', host)
  313. // Note: This call needs to be delayed until the user has interacted with
  314. // the page in some way (which at this point they have), see: https://goo.gl/7K7WLu
  315. this.connector.setSampleRate(audioContext().sampleRate)
  316. // TODO: token
  317. this.connector.connect(`wss://${host}:${port}`, {
  318. username: username,
  319. password: password,
  320. tokens: tokens
  321. }).done(client => {
  322. log('Connected!')
  323. this.client = client
  324. // Prepare for connection errors
  325. client.on('error', (err) => {
  326. log('Connection error:', err)
  327. this.resetClient()
  328. })
  329. // Make sure we stay open if we're running as Matrix widget
  330. window.matrixWidget.setAlwaysOnScreen(true)
  331. // Register all channels, recursively
  332. if(channelName.indexOf("/") != 0) {
  333. channelName = "/"+channelName;
  334. }
  335. const registerChannel = (channel, channelPath) => {
  336. this._newChannel(channel)
  337. if(channelPath === channelName) {
  338. client.self.setChannel(channel)
  339. }
  340. channel.children.forEach(ch => registerChannel(ch, channelPath+"/"+ch.name))
  341. }
  342. registerChannel(client.root, "")
  343. // Register all users
  344. client.users.forEach(user => this._newUser(user))
  345. // Register future channels
  346. client.on('newChannel', channel => this._newChannel(channel))
  347. // Register future users
  348. client.on('newUser', user => this._newUser(user))
  349. // Handle messages
  350. client.on('message', (sender, message, users, channels, trees) => {
  351. sender = sender || { __ui: 'Server' }
  352. ui.log.push({
  353. type: 'chat-message',
  354. user: sender.__ui,
  355. channel: channels.length > 0,
  356. message: sanitize(message)
  357. })
  358. })
  359. // Log permission denied error messages
  360. client.on('denied', (type) => {
  361. ui.log.push({
  362. type: 'generic',
  363. value: 'Permission denied : '+ type
  364. })
  365. })
  366. // Set own user and root channel
  367. this.thisUser(client.self.__ui)
  368. this.root(client.root.__ui)
  369. // Upate linked channels
  370. this._updateLinks()
  371. // Log welcome message
  372. if (client.welcomeMessage) {
  373. this.log.push({
  374. type: 'welcome-message',
  375. message: sanitize(client.welcomeMessage)
  376. })
  377. }
  378. // Startup audio input processing
  379. this._updateVoiceHandler()
  380. // Tell server our mute/deaf state (if necessary)
  381. if (this.selfDeaf()) {
  382. this.client.setSelfDeaf(true)
  383. } else if (this.selfMute()) {
  384. this.client.setSelfMute(true)
  385. }
  386. }, err => {
  387. if (err.$type && err.$type.name === 'Reject') {
  388. this.connectErrorDialog.type(err.type)
  389. this.connectErrorDialog.reason(err.reason)
  390. this.connectErrorDialog.show()
  391. } else {
  392. log('Connection error:', err)
  393. }
  394. })
  395. }
  396. this._newUser = user => {
  397. const simpleProperties = {
  398. uniqueId: 'uid',
  399. username: 'name',
  400. mute: 'mute',
  401. deaf: 'deaf',
  402. suppress: 'suppress',
  403. selfMute: 'selfMute',
  404. selfDeaf: 'selfDeaf',
  405. texture: 'rawTexture',
  406. textureHash: 'textureHash',
  407. comment: 'comment'
  408. }
  409. var ui = user.__ui = {
  410. model: user,
  411. talking: ko.observable('off'),
  412. channel: ko.observable()
  413. }
  414. ui.texture = ko.pureComputed(() => {
  415. let raw = ui.rawTexture()
  416. if (!raw || raw.offset >= raw.limit) return null
  417. return 'data:image/*;base64,' + ByteBuffer.wrap(raw).toBase64()
  418. })
  419. ui.show_avatar = () => {
  420. let setting = this.settings.showAvatars()
  421. switch (setting) {
  422. case 'always':
  423. break
  424. case 'own_channel':
  425. if (this.thisUser().channel() !== ui.channel()) return false
  426. break
  427. case 'linked_channel':
  428. if (!ui.channel().linked()) return false
  429. break
  430. case 'minimal_only':
  431. if (!this.minimalView()) return false
  432. if (this.thisUser().channel() !== ui.channel()) return false
  433. break
  434. case 'never':
  435. default: return false
  436. }
  437. if (!ui.texture()) {
  438. if (ui.textureHash()) {
  439. // The user has an avatar set but it's of sufficient size to not be
  440. // included by default, so we need to fetch it explicitly now.
  441. // mumble-client should make sure we only send one request per hash
  442. user.requestTexture()
  443. }
  444. return false
  445. }
  446. return true
  447. }
  448. ui.openContextMenu = (_, event) => openContextMenu(event, this.userContextMenu, ui)
  449. ui.canChangeMute = () => {
  450. return false // TODO check for perms and implement
  451. }
  452. ui.canChangeDeafen = () => {
  453. return false // TODO check for perms and implement
  454. }
  455. ui.canChangePrioritySpeaker = () => {
  456. return false // TODO check for perms and implement
  457. }
  458. ui.canLocalMute = () => {
  459. return false // TODO implement local mute
  460. // return this.thisUser() !== ui
  461. }
  462. ui.canIgnoreMessages = () => {
  463. return false // TODO implement ignore messages
  464. // return this.thisUser() !== ui
  465. }
  466. ui.canChangeComment = () => {
  467. return false // TODO implement changing of comments
  468. // return this.thisUser() === ui // TODO check for perms
  469. }
  470. ui.canChangeAvatar = () => {
  471. return this.thisUser() === ui // TODO check for perms
  472. }
  473. ui.toggleMute = () => {
  474. if (ui.selfMute()) {
  475. this.requestUnmute(ui)
  476. } else {
  477. this.requestMute(ui)
  478. }
  479. }
  480. ui.toggleDeaf = () => {
  481. if (ui.selfDeaf()) {
  482. this.requestUndeaf(ui)
  483. } else {
  484. this.requestDeaf(ui)
  485. }
  486. }
  487. ui.viewAvatar = () => {
  488. this.avatarView(ui.texture())
  489. }
  490. ui.changeAvatar = () => {
  491. let input = document.createElement('input')
  492. input.type = 'file'
  493. input.addEventListener('change', () => {
  494. let reader = new window.FileReader()
  495. reader.onload = () => {
  496. this.client.setSelfTexture(reader.result)
  497. }
  498. reader.readAsArrayBuffer(input.files[0])
  499. })
  500. input.click()
  501. }
  502. ui.removeAvatar = () => {
  503. user.clearTexture()
  504. }
  505. Object.entries(simpleProperties).forEach(key => {
  506. ui[key[1]] = ko.observable(user[key[0]])
  507. })
  508. ui.state = ko.pureComputed(userToState, ui)
  509. if (user.channel) {
  510. ui.channel(user.channel.__ui)
  511. ui.channel().users.push(ui)
  512. ui.channel().users.sort(compareUsers)
  513. }
  514. user.on('update', (actor, properties) => {
  515. Object.entries(simpleProperties).forEach(key => {
  516. if (properties[key[0]] !== undefined) {
  517. ui[key[1]](properties[key[0]])
  518. }
  519. })
  520. if (properties.channel !== undefined) {
  521. if (ui.channel()) {
  522. ui.channel().users.remove(ui)
  523. }
  524. ui.channel(properties.channel.__ui)
  525. ui.channel().users.push(ui)
  526. ui.channel().users.sort(compareUsers)
  527. this._updateLinks()
  528. }
  529. if (properties.textureHash !== undefined) {
  530. // Invalidate avatar texture when its hash has changed
  531. // If the avatar is still visible, this will trigger a fetch of the new one.
  532. ui.rawTexture(null)
  533. }
  534. }).on('remove', () => {
  535. if (ui.channel()) {
  536. ui.channel().users.remove(ui)
  537. }
  538. }).on('voice', stream => {
  539. console.log(`User ${user.username} started takling`)
  540. var userNode = new BufferQueueNode({
  541. audioContext: audioContext()
  542. })
  543. userNode.connect(audioContext().destination)
  544. stream.on('data', data => {
  545. if (data.target === 'normal') {
  546. ui.talking('on')
  547. } else if (data.target === 'shout') {
  548. ui.talking('shout')
  549. } else if (data.target === 'whisper') {
  550. ui.talking('whisper')
  551. }
  552. userNode.write(data.buffer)
  553. }).on('end', () => {
  554. console.log(`User ${user.username} stopped takling`)
  555. ui.talking('off')
  556. userNode.end()
  557. })
  558. })
  559. }
  560. this._newChannel = channel => {
  561. const simpleProperties = {
  562. position: 'position',
  563. name: 'name',
  564. description: 'description'
  565. }
  566. var ui = channel.__ui = {
  567. model: channel,
  568. expanded: ko.observable(true),
  569. parent: ko.observable(),
  570. channels: ko.observableArray(),
  571. users: ko.observableArray(),
  572. linked: ko.observable(false)
  573. }
  574. ui.userCount = () => {
  575. return ui.channels().reduce((acc, c) => acc + c.userCount(), ui.users().length)
  576. }
  577. ui.openContextMenu = (_, event) => openContextMenu(event, this.channelContextMenu, ui)
  578. ui.canJoin = () => {
  579. return true // TODO check for perms
  580. }
  581. ui.canAdd = () => {
  582. return false // TODO check for perms and implement
  583. }
  584. ui.canEdit = () => {
  585. return false // TODO check for perms and implement
  586. }
  587. ui.canRemove = () => {
  588. return false // TODO check for perms and implement
  589. }
  590. ui.canLink = () => {
  591. return false // TODO check for perms and implement
  592. }
  593. ui.canUnlink = () => {
  594. return false // TODO check for perms and implement
  595. }
  596. ui.canSendMessage = () => {
  597. return false // TODO check for perms and implement
  598. }
  599. Object.entries(simpleProperties).forEach(key => {
  600. ui[key[1]] = ko.observable(channel[key[0]])
  601. })
  602. if (channel.parent) {
  603. ui.parent(channel.parent.__ui)
  604. ui.parent().channels.push(ui)
  605. ui.parent().channels.sort(compareChannels)
  606. }
  607. this._updateLinks()
  608. channel.on('update', properties => {
  609. Object.entries(simpleProperties).forEach(key => {
  610. if (properties[key[0]] !== undefined) {
  611. ui[key[1]](properties[key[0]])
  612. }
  613. })
  614. if (properties.parent !== undefined) {
  615. if (ui.parent()) {
  616. ui.parent().channel.remove(ui)
  617. }
  618. ui.parent(properties.parent.__ui)
  619. ui.parent().channels.push(ui)
  620. ui.parent().channels.sort(compareChannels)
  621. }
  622. if (properties.links !== undefined) {
  623. this._updateLinks()
  624. }
  625. }).on('remove', () => {
  626. if (ui.parent()) {
  627. ui.parent().channels.remove(ui)
  628. }
  629. this._updateLinks()
  630. })
  631. }
  632. this.resetClient = () => {
  633. if (this.client) {
  634. this.client.disconnect()
  635. }
  636. this.client = null
  637. this.selected(null).root(null).thisUser(null)
  638. }
  639. this.connected = () => this.thisUser() != null
  640. this._updateVoiceHandler = () => {
  641. if (!this.client) {
  642. return
  643. }
  644. if (voiceHandler) {
  645. voiceHandler.end()
  646. voiceHandler = null
  647. }
  648. let mode = this.settings.voiceMode
  649. if (mode === 'cont') {
  650. voiceHandler = new ContinuousVoiceHandler(this.client, this.settings)
  651. } else if (mode === 'ptt') {
  652. voiceHandler = new PushToTalkVoiceHandler(this.client, this.settings)
  653. } else if (mode === 'vad') {
  654. voiceHandler = new VADVoiceHandler(this.client, this.settings)
  655. } else {
  656. log('Unknown voice mode:', mode)
  657. return
  658. }
  659. voiceHandler.on('started_talking', () => {
  660. if (this.thisUser()) {
  661. this.thisUser().talking('on')
  662. }
  663. })
  664. voiceHandler.on('stopped_talking', () => {
  665. if (this.thisUser()) {
  666. this.thisUser().talking('off')
  667. }
  668. })
  669. if (this.selfMute()) {
  670. voiceHandler.setMute(true)
  671. }
  672. this.client.setAudioQuality(
  673. this.settings.audioBitrate,
  674. this.settings.samplesPerPacket
  675. )
  676. }
  677. this.messageBoxHint = ko.pureComputed(() => {
  678. if (!this.thisUser()) {
  679. return '' // Not yet connected
  680. }
  681. var target = this.selected()
  682. if (!target) {
  683. target = this.thisUser()
  684. }
  685. if (target === this.thisUser()) {
  686. target = target.channel()
  687. }
  688. if (target.users) { // Channel
  689. return "Type message to channel '" + target.name() + "' here"
  690. } else { // User
  691. return "Type message to user '" + target.name() + "' here"
  692. }
  693. })
  694. this.submitMessageBox = () => {
  695. this.sendMessage(this.selected(), this.messageBox())
  696. this.messageBox('')
  697. }
  698. this.sendMessage = (target, message) => {
  699. if (this.connected()) {
  700. // If no target is selected, choose our own user
  701. if (!target) {
  702. target = this.thisUser()
  703. }
  704. // If target is our own user, send to our channel
  705. if (target === this.thisUser()) {
  706. target = target.channel()
  707. }
  708. // Send message
  709. target.model.sendMessage(message)
  710. if (target.users) { // Channel
  711. this.log.push({
  712. type: 'chat-message-self',
  713. message: sanitize(message),
  714. channel: target
  715. })
  716. } else { // User
  717. this.log.push({
  718. type: 'chat-message-self',
  719. message: sanitize(message),
  720. user: target
  721. })
  722. }
  723. }
  724. }
  725. this.requestMove = (user, channel) => {
  726. if (this.connected()) {
  727. user.model.setChannel(channel.model)
  728. let currentUrl = url.parse(document.location.href, true)
  729. // delete search param so that query one can be taken into account
  730. delete currentUrl.search
  731. // get full channel path
  732. if( channel.parent() ){ // in case this channel is not Root
  733. let parent = channel.parent()
  734. currentUrl.query.channelName = channel.name()
  735. while( parent.parent() ){
  736. currentUrl.query.channelName = parent.name() + '/' + currentUrl.query.channelName
  737. parent = parent.parent()
  738. }
  739. } else {
  740. // there is no channelName as we moved to Root
  741. delete currentUrl.query.channelName
  742. }
  743. // reflect this change in URL
  744. window.history.pushState(null, channel.name(), url.format(currentUrl))
  745. }
  746. }
  747. this.requestMute = user => {
  748. if (user === this.thisUser()) {
  749. this.selfMute(true)
  750. }
  751. if (this.connected()) {
  752. if (user === this.thisUser()) {
  753. this.client.setSelfMute(true)
  754. } else {
  755. user.model.setMute(true)
  756. }
  757. }
  758. }
  759. this.requestDeaf = user => {
  760. if (user === this.thisUser()) {
  761. this.selfMute(true)
  762. this.selfDeaf(true)
  763. }
  764. if (this.connected()) {
  765. if (user === this.thisUser()) {
  766. this.client.setSelfDeaf(true)
  767. } else {
  768. user.model.setDeaf(true)
  769. }
  770. }
  771. }
  772. this.requestUnmute = user => {
  773. if (user === this.thisUser()) {
  774. this.selfMute(false)
  775. this.selfDeaf(false)
  776. }
  777. if (this.connected()) {
  778. if (user === this.thisUser()) {
  779. this.client.setSelfMute(false)
  780. } else {
  781. user.model.setMute(false)
  782. }
  783. }
  784. }
  785. this.requestUndeaf = user => {
  786. if (user === this.thisUser()) {
  787. this.selfDeaf(false)
  788. }
  789. if (this.connected()) {
  790. if (user === this.thisUser()) {
  791. this.client.setSelfDeaf(false)
  792. } else {
  793. user.model.setDeaf(false)
  794. }
  795. }
  796. }
  797. this._updateLinks = () => {
  798. if (!this.thisUser()) {
  799. return
  800. }
  801. var allChannels = getAllChannels(this.root(), [])
  802. var ownChannel = this.thisUser().channel().model
  803. var allLinked = findLinks(ownChannel, [])
  804. allChannels.forEach(channel => {
  805. channel.linked(allLinked.indexOf(channel.model) !== -1)
  806. })
  807. function findLinks (channel, knownLinks) {
  808. knownLinks.push(channel)
  809. channel.links.forEach(next => {
  810. if (next && knownLinks.indexOf(next) === -1) {
  811. findLinks(next, knownLinks)
  812. }
  813. })
  814. allChannels.map(c => c.model).forEach(next => {
  815. if (next && knownLinks.indexOf(next) === -1 && next.links.indexOf(channel) !== -1) {
  816. findLinks(next, knownLinks)
  817. }
  818. })
  819. return knownLinks
  820. }
  821. function getAllChannels (channel, channels) {
  822. channels.push(channel)
  823. channel.channels().forEach(next => getAllChannels(next, channels))
  824. return channels
  825. }
  826. }
  827. this.openSourceCode = () => {
  828. var homepage = require('../package.json').homepage
  829. window.open(homepage, '_blank').focus()
  830. }
  831. this.updateSize = () => {
  832. this.minimalView(window.innerWidth < 320)
  833. if (this.minimalView()) {
  834. this.toolbarHorizontal(window.innerWidth < window.innerHeight)
  835. } else {
  836. this.toolbarHorizontal(!this.settings.toolbarVertical)
  837. }
  838. }
  839. }
  840. }
  841. var ui = new GlobalBindings(window.mumbleWebConfig)
  842. // Used only for debugging
  843. window.mumbleUi = ui
  844. function initializeUI () {
  845. var queryParams = url.parse(document.location.href, true).query
  846. queryParams = Object.assign({}, window.mumbleWebConfig.defaults, queryParams)
  847. var useJoinDialog = queryParams.joinDialog
  848. if (queryParams.matrix) {
  849. useJoinDialog = true
  850. }
  851. if (queryParams.address) {
  852. ui.connectDialog.address(queryParams.address)
  853. } else {
  854. useJoinDialog = false
  855. }
  856. if (queryParams.port) {
  857. ui.connectDialog.port(queryParams.port)
  858. } else {
  859. useJoinDialog = false
  860. }
  861. if (queryParams.token) {
  862. var tokens = queryParams.token
  863. if (!Array.isArray(tokens)) {
  864. tokens = [tokens]
  865. }
  866. ui.connectDialog.tokens(tokens)
  867. }
  868. if (queryParams.username) {
  869. ui.connectDialog.username(queryParams.username)
  870. } else {
  871. useJoinDialog = false
  872. }
  873. if (queryParams.password) {
  874. ui.connectDialog.password(queryParams.password)
  875. }
  876. if (queryParams.channelName) {
  877. ui.connectDialog.channelName(queryParams.channelName)
  878. }
  879. if (queryParams.avatarurl) {
  880. // Download the avatar and upload it to the mumble server when connected
  881. let url = queryParams.avatarurl
  882. console.log('Fetching avatar from', url)
  883. let req = new window.XMLHttpRequest()
  884. req.open('GET', url, true)
  885. req.responseType = 'arraybuffer'
  886. req.onload = () => {
  887. let upload = (avatar) => {
  888. if (req.response) {
  889. console.log('Uploading user avatar to server')
  890. ui.client.setSelfTexture(req.response)
  891. }
  892. }
  893. // On any future connections
  894. ui.thisUser.subscribe((thisUser) => {
  895. if (thisUser) {
  896. upload()
  897. }
  898. })
  899. // And the current one (if already connected)
  900. if (ui.thisUser()) {
  901. upload()
  902. }
  903. }
  904. req.send()
  905. }
  906. ui.connectDialog.joinOnly(useJoinDialog)
  907. ko.applyBindings(ui)
  908. window.onresize = () => ui.updateSize()
  909. ui.updateSize()
  910. }
  911. function log () {
  912. console.log.apply(console, arguments)
  913. var args = []
  914. for (var i = 0; i < arguments.length; i++) {
  915. args.push(arguments[i])
  916. }
  917. ui.log.push({
  918. type: 'generic',
  919. value: args.join(' ')
  920. })
  921. }
  922. function compareChannels (c1, c2) {
  923. if (c1.position() === c2.position()) {
  924. return c1.name() === c2.name() ? 0 : c1.name() < c2.name() ? -1 : 1
  925. }
  926. return c1.position() - c2.position()
  927. }
  928. function compareUsers (u1, u2) {
  929. return u1.name() === u2.name() ? 0 : u1.name() < u2.name() ? -1 : 1
  930. }
  931. function userToState () {
  932. var flags = []
  933. // TODO: Friend
  934. if (this.uid()) {
  935. flags.push('Authenticated')
  936. }
  937. // TODO: Priority Speaker, Recording
  938. if (this.mute()) {
  939. flags.push('Muted (server)')
  940. }
  941. if (this.deaf()) {
  942. flags.push('Deafened (server)')
  943. }
  944. // TODO: Local Ignore (Text messages), Local Mute
  945. if (this.selfMute()) {
  946. flags.push('Muted (self)')
  947. }
  948. if (this.selfDeaf()) {
  949. flags.push('Deafened (self)')
  950. }
  951. return flags.join(', ')
  952. }
  953. var voiceHandler
  954. var testVoiceHandler
  955. /**
  956. * @author svartoyg
  957. */
  958. function translatePiece(selector, kind, parameters, key) {
  959. let element = document.querySelector(selector);
  960. if (element !== null) {
  961. const translation = translate(key);
  962. switch (kind) {
  963. default:
  964. console.warn('unhandled dom translation kind "' + kind + '"');
  965. break;
  966. case 'textcontent':
  967. element.textContent = translation;
  968. break;
  969. case 'attribute':
  970. element.setAttribute(parameters.name || 'value', translation);
  971. break;
  972. }
  973. } else {
  974. console.warn(`translation selector "${selector}" for "${key}" did not match any element`)
  975. }
  976. }
  977. /**
  978. * @author svartoyg
  979. */
  980. function translateEverything() {
  981. translatePiece('#connect-dialog_title', 'textcontent', {}, 'connectdialog.title');
  982. translatePiece('#connect-dialog_input_address', 'textcontent', {}, 'connectdialog.address');
  983. translatePiece('#connect-dialog_input_port', 'textcontent', {}, 'connectdialog.port');
  984. translatePiece('#connect-dialog_input_username', 'textcontent', {}, 'connectdialog.username');
  985. translatePiece('#connect-dialog_input_password', 'textcontent', {}, 'connectdialog.password');
  986. translatePiece('#connect-dialog_input_tokens', 'textcontent', {}, 'connectdialog.tokens');
  987. translatePiece('#connect-dialog_controls_remove', 'textcontent', {}, 'connectdialog.remove');
  988. translatePiece('#connect-dialog_controls_add', 'textcontent', {}, 'connectdialog.add');
  989. translatePiece('#connect-dialog_controls_cancel', 'attribute', {'name': 'value'}, 'connectdialog.cancel');
  990. translatePiece('#connect-dialog_controls_connect', 'attribute', {'name': 'value'}, 'connectdialog.connect');
  991. translatePiece('.connect-dialog.error-dialog .dialog-header', 'textcontent', {}, 'connectdialog.error.title');
  992. translatePiece('.connect-dialog.error-dialog .reason .refused', 'textcontent', {}, 'connectdialog.error.reason.refused');
  993. translatePiece('.connect-dialog.error-dialog .reason .version', 'textcontent', {}, 'connectdialog.error.reason.version');
  994. translatePiece('.connect-dialog.error-dialog .reason .username', 'textcontent', {}, 'connectdialog.error.reason.username');
  995. translatePiece('.connect-dialog.error-dialog .reason .userpassword', 'textcontent', {}, 'connectdialog.error.reason.userpassword');
  996. translatePiece('.connect-dialog.error-dialog .reason .serverpassword', 'textcontent', {}, 'connectdialog.error.reason.serverpassword');
  997. translatePiece('.connect-dialog.error-dialog .reason .username-in-use', 'textcontent', {}, 'connectdialog.error.reason.username_in_use');
  998. translatePiece('.connect-dialog.error-dialog .reason .full', 'textcontent', {}, 'connectdialog.error.reason.full');
  999. translatePiece('.connect-dialog.error-dialog .reason .clientcert', 'textcontent', {}, 'connectdialog.error.reason.clientcert');
  1000. translatePiece('.connect-dialog.error-dialog .reason .server', 'textcontent', {}, 'connectdialog.error.reason.server');
  1001. translatePiece('.connect-dialog.error-dialog .alternate-username', 'textcontent', {}, 'connectdialog.username');
  1002. translatePiece('.connect-dialog.error-dialog .alternate-password', 'textcontent', {}, 'connectdialog.password');
  1003. translatePiece('.connect-dialog.error-dialog .dialog-submit', 'attribute', {'name': 'value'}, 'connectdialog.error.retry');
  1004. translatePiece('.connect-dialog.error-dialog .dialog-close', 'attribute', {'name': 'value'}, 'connectdialog.error.cancel');
  1005. translatePiece('.join-dialog .dialog-header', 'textcontent', {}, 'joindialog.title');
  1006. translatePiece('.join-dialog .dialog-submit', 'attribute', {'name': 'value'}, 'joindialog.connect');
  1007. translatePiece('.user-context-menu .mute', 'textcontent', {}, 'contextmenu.mute');
  1008. translatePiece('.user-context-menu .deafen', 'textcontent', {}, 'contextmenu.deafen');
  1009. translatePiece('.user-context-menu .priority-speaker', 'textcontent', {}, 'usercontextmenu.priority_speaker');
  1010. translatePiece('.user-context-menu .local-mute', 'textcontent', {}, 'usercontextmenu.local_mute');
  1011. translatePiece('.user-context-menu .ignore-messages', 'textcontent', {}, 'usercontextmenu.ignore_messages');
  1012. translatePiece('.user-context-menu .view-comment', 'textcontent', {}, 'usercontextmenu.view_comment');
  1013. translatePiece('.user-context-menu .change-comment', 'textcontent', {}, 'usercontextmenu.change_comment');
  1014. translatePiece('.user-context-menu .reset-comment', 'textcontent', {}, 'usercontextmenu.reset_comment');
  1015. translatePiece('.user-context-menu .view-avatar', 'textcontent', {}, 'usercontextmenu.view_avatar');
  1016. translatePiece('.user-context-menu .change-avatar', 'textcontent', {}, 'usercontextmenu.change_avatar');
  1017. translatePiece('.user-context-menu .reset-avatar', 'textcontent', {}, 'usercontextmenu.reset_avatar');
  1018. translatePiece('.user-context-menu .send-message', 'textcontent', {}, 'usercontextmenu.send_message');
  1019. translatePiece('.user-context-menu .information', 'textcontent', {}, 'usercontextmenu.information');
  1020. translatePiece('.user-context-menu .self-mute', 'textcontent', {}, 'usercontextmenu.self_mute');
  1021. translatePiece('.user-context-menu .self-deafen', 'textcontent', {}, 'usercontextmenu.self_deafen');
  1022. translatePiece('.user-context-menu .add-friend', 'textcontent', {}, 'usercontextmenu.add_friend');
  1023. translatePiece('.user-context-menu .remove-friend', 'textcontent', {}, 'usercontextmenu.remove_friend');
  1024. translatePiece('.channel-context-menu .join', 'textcontent', {}, 'channelcontextmenu.join');
  1025. translatePiece('.channel-context-menu .add', 'textcontent', {}, 'channelcontextmenu.add');
  1026. translatePiece('.channel-context-menu .edit', 'textcontent', {}, 'channelcontextmenu.edit');
  1027. translatePiece('.channel-context-menu .remove', 'textcontent', {}, 'channelcontextmenu.remove');
  1028. translatePiece('.channel-context-menu .link', 'textcontent', {}, 'channelcontextmenu.link');
  1029. translatePiece('.channel-context-menu .unlink', 'textcontent', {}, 'channelcontextmenu.unlink');
  1030. translatePiece('.channel-context-menu .unlink-all', 'textcontent', {}, 'channelcontextmenu.unlink_all');
  1031. translatePiece('.channel-context-menu .copy-mumble-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_url');
  1032. translatePiece('.channel-context-menu .copy-mumble-web-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_web_url');
  1033. translatePiece('.channel-context-menu .send-message', 'textcontent', {}, 'channelcontextmenu.send_message');
  1034. }
  1035. async function main() {
  1036. await localizationInitialize(navigator.language);
  1037. translateEverything();
  1038. initializeUI();
  1039. initVoice(data => {
  1040. if (testVoiceHandler) {
  1041. testVoiceHandler.write(data)
  1042. }
  1043. if (!ui.client) {
  1044. if (voiceHandler) {
  1045. voiceHandler.end()
  1046. }
  1047. voiceHandler = null
  1048. } else if (voiceHandler) {
  1049. voiceHandler.write(data)
  1050. }
  1051. }, err => {
  1052. log('Cannot initialize user media. Microphone will not work:', err)
  1053. })
  1054. }
  1055. window.onload = main