index.js 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151
  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(translate('logentry.connecting'), 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(translate('logentry.connected'))
  323. this.client = client
  324. // Prepare for connection errors
  325. client.on('error', (err) => {
  326. log(translate('logentry.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(translate('logentry.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(translate('logentry.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 translate('chat.channel_message_placeholder')
  690. .replace('%1', target.name())
  691. } else { // User
  692. return translate('chat.user_message_placeholder')
  693. .replace('%1', target.name())
  694. }
  695. })
  696. this.submitMessageBox = () => {
  697. this.sendMessage(this.selected(), this.messageBox())
  698. this.messageBox('')
  699. }
  700. this.sendMessage = (target, message) => {
  701. if (this.connected()) {
  702. // If no target is selected, choose our own user
  703. if (!target) {
  704. target = this.thisUser()
  705. }
  706. // If target is our own user, send to our channel
  707. if (target === this.thisUser()) {
  708. target = target.channel()
  709. }
  710. // Send message
  711. target.model.sendMessage(message)
  712. if (target.users) { // Channel
  713. this.log.push({
  714. type: 'chat-message-self',
  715. message: sanitize(message),
  716. channel: target
  717. })
  718. } else { // User
  719. this.log.push({
  720. type: 'chat-message-self',
  721. message: sanitize(message),
  722. user: target
  723. })
  724. }
  725. }
  726. }
  727. this.requestMove = (user, channel) => {
  728. if (this.connected()) {
  729. user.model.setChannel(channel.model)
  730. let currentUrl = url.parse(document.location.href, true)
  731. // delete search param so that query one can be taken into account
  732. delete currentUrl.search
  733. // get full channel path
  734. if( channel.parent() ){ // in case this channel is not Root
  735. let parent = channel.parent()
  736. currentUrl.query.channelName = channel.name()
  737. while( parent.parent() ){
  738. currentUrl.query.channelName = parent.name() + '/' + currentUrl.query.channelName
  739. parent = parent.parent()
  740. }
  741. } else {
  742. // there is no channelName as we moved to Root
  743. delete currentUrl.query.channelName
  744. }
  745. // reflect this change in URL
  746. window.history.pushState(null, channel.name(), url.format(currentUrl))
  747. }
  748. }
  749. this.requestMute = user => {
  750. if (user === this.thisUser()) {
  751. this.selfMute(true)
  752. }
  753. if (this.connected()) {
  754. if (user === this.thisUser()) {
  755. this.client.setSelfMute(true)
  756. } else {
  757. user.model.setMute(true)
  758. }
  759. }
  760. }
  761. this.requestDeaf = user => {
  762. if (user === this.thisUser()) {
  763. this.selfMute(true)
  764. this.selfDeaf(true)
  765. }
  766. if (this.connected()) {
  767. if (user === this.thisUser()) {
  768. this.client.setSelfDeaf(true)
  769. } else {
  770. user.model.setDeaf(true)
  771. }
  772. }
  773. }
  774. this.requestUnmute = user => {
  775. if (user === this.thisUser()) {
  776. this.selfMute(false)
  777. this.selfDeaf(false)
  778. }
  779. if (this.connected()) {
  780. if (user === this.thisUser()) {
  781. this.client.setSelfMute(false)
  782. } else {
  783. user.model.setMute(false)
  784. }
  785. }
  786. }
  787. this.requestUndeaf = user => {
  788. if (user === this.thisUser()) {
  789. this.selfDeaf(false)
  790. }
  791. if (this.connected()) {
  792. if (user === this.thisUser()) {
  793. this.client.setSelfDeaf(false)
  794. } else {
  795. user.model.setDeaf(false)
  796. }
  797. }
  798. }
  799. this._updateLinks = () => {
  800. if (!this.thisUser()) {
  801. return
  802. }
  803. var allChannels = getAllChannels(this.root(), [])
  804. var ownChannel = this.thisUser().channel().model
  805. var allLinked = findLinks(ownChannel, [])
  806. allChannels.forEach(channel => {
  807. channel.linked(allLinked.indexOf(channel.model) !== -1)
  808. })
  809. function findLinks (channel, knownLinks) {
  810. knownLinks.push(channel)
  811. channel.links.forEach(next => {
  812. if (next && knownLinks.indexOf(next) === -1) {
  813. findLinks(next, knownLinks)
  814. }
  815. })
  816. allChannels.map(c => c.model).forEach(next => {
  817. if (next && knownLinks.indexOf(next) === -1 && next.links.indexOf(channel) !== -1) {
  818. findLinks(next, knownLinks)
  819. }
  820. })
  821. return knownLinks
  822. }
  823. function getAllChannels (channel, channels) {
  824. channels.push(channel)
  825. channel.channels().forEach(next => getAllChannels(next, channels))
  826. return channels
  827. }
  828. }
  829. this.openSourceCode = () => {
  830. var homepage = require('../package.json').homepage
  831. window.open(homepage, '_blank').focus()
  832. }
  833. this.updateSize = () => {
  834. this.minimalView(window.innerWidth < 320)
  835. if (this.minimalView()) {
  836. this.toolbarHorizontal(window.innerWidth < window.innerHeight)
  837. } else {
  838. this.toolbarHorizontal(!this.settings.toolbarVertical)
  839. }
  840. }
  841. }
  842. }
  843. var ui = new GlobalBindings(window.mumbleWebConfig)
  844. // Used only for debugging
  845. window.mumbleUi = ui
  846. function initializeUI () {
  847. var queryParams = url.parse(document.location.href, true).query
  848. queryParams = Object.assign({}, window.mumbleWebConfig.defaults, queryParams)
  849. var useJoinDialog = queryParams.joinDialog
  850. if (queryParams.matrix) {
  851. useJoinDialog = true
  852. }
  853. if (queryParams.address) {
  854. ui.connectDialog.address(queryParams.address)
  855. } else {
  856. useJoinDialog = false
  857. }
  858. if (queryParams.port) {
  859. ui.connectDialog.port(queryParams.port)
  860. } else {
  861. useJoinDialog = false
  862. }
  863. if (queryParams.token) {
  864. var tokens = queryParams.token
  865. if (!Array.isArray(tokens)) {
  866. tokens = [tokens]
  867. }
  868. ui.connectDialog.tokens(tokens)
  869. }
  870. if (queryParams.username) {
  871. ui.connectDialog.username(queryParams.username)
  872. } else {
  873. useJoinDialog = false
  874. }
  875. if (queryParams.password) {
  876. ui.connectDialog.password(queryParams.password)
  877. }
  878. if (queryParams.channelName) {
  879. ui.connectDialog.channelName(queryParams.channelName)
  880. }
  881. if (queryParams.avatarurl) {
  882. // Download the avatar and upload it to the mumble server when connected
  883. let url = queryParams.avatarurl
  884. console.log('Fetching avatar from', url)
  885. let req = new window.XMLHttpRequest()
  886. req.open('GET', url, true)
  887. req.responseType = 'arraybuffer'
  888. req.onload = () => {
  889. let upload = (avatar) => {
  890. if (req.response) {
  891. console.log('Uploading user avatar to server')
  892. ui.client.setSelfTexture(req.response)
  893. }
  894. }
  895. // On any future connections
  896. ui.thisUser.subscribe((thisUser) => {
  897. if (thisUser) {
  898. upload()
  899. }
  900. })
  901. // And the current one (if already connected)
  902. if (ui.thisUser()) {
  903. upload()
  904. }
  905. }
  906. req.send()
  907. }
  908. ui.connectDialog.joinOnly(useJoinDialog)
  909. ko.applyBindings(ui)
  910. window.onresize = () => ui.updateSize()
  911. ui.updateSize()
  912. }
  913. function log () {
  914. console.log.apply(console, arguments)
  915. var args = []
  916. for (var i = 0; i < arguments.length; i++) {
  917. args.push(arguments[i])
  918. }
  919. ui.log.push({
  920. type: 'generic',
  921. value: args.join(' ')
  922. })
  923. }
  924. function compareChannels (c1, c2) {
  925. if (c1.position() === c2.position()) {
  926. return c1.name() === c2.name() ? 0 : c1.name() < c2.name() ? -1 : 1
  927. }
  928. return c1.position() - c2.position()
  929. }
  930. function compareUsers (u1, u2) {
  931. return u1.name() === u2.name() ? 0 : u1.name() < u2.name() ? -1 : 1
  932. }
  933. function userToState () {
  934. var flags = []
  935. // TODO: Friend
  936. if (this.uid()) {
  937. flags.push('Authenticated')
  938. }
  939. // TODO: Priority Speaker, Recording
  940. if (this.mute()) {
  941. flags.push('Muted (server)')
  942. }
  943. if (this.deaf()) {
  944. flags.push('Deafened (server)')
  945. }
  946. // TODO: Local Ignore (Text messages), Local Mute
  947. if (this.selfMute()) {
  948. flags.push('Muted (self)')
  949. }
  950. if (this.selfDeaf()) {
  951. flags.push('Deafened (self)')
  952. }
  953. return flags.join(', ')
  954. }
  955. var voiceHandler
  956. var testVoiceHandler
  957. /**
  958. * @author svartoyg
  959. */
  960. function translatePiece(selector, kind, parameters, key) {
  961. let element = document.querySelector(selector);
  962. if (element !== null) {
  963. const translation = translate(key);
  964. switch (kind) {
  965. default:
  966. console.warn('unhandled dom translation kind "' + kind + '"');
  967. break;
  968. case 'textcontent':
  969. element.textContent = translation;
  970. break;
  971. case 'attribute':
  972. element.setAttribute(parameters.name || 'value', translation);
  973. break;
  974. }
  975. } else {
  976. console.warn(`translation selector "${selector}" for "${key}" did not match any element`)
  977. }
  978. }
  979. /**
  980. * @author svartoyg
  981. */
  982. function translateEverything() {
  983. translatePiece('#connect-dialog_title', 'textcontent', {}, 'connectdialog.title');
  984. translatePiece('#connect-dialog_input_address', 'textcontent', {}, 'connectdialog.address');
  985. translatePiece('#connect-dialog_input_port', 'textcontent', {}, 'connectdialog.port');
  986. translatePiece('#connect-dialog_input_username', 'textcontent', {}, 'connectdialog.username');
  987. translatePiece('#connect-dialog_input_password', 'textcontent', {}, 'connectdialog.password');
  988. translatePiece('#connect-dialog_input_tokens', 'textcontent', {}, 'connectdialog.tokens');
  989. translatePiece('#connect-dialog_controls_remove', 'textcontent', {}, 'connectdialog.remove');
  990. translatePiece('#connect-dialog_controls_add', 'textcontent', {}, 'connectdialog.add');
  991. translatePiece('#connect-dialog_controls_cancel', 'attribute', {'name': 'value'}, 'connectdialog.cancel');
  992. translatePiece('#connect-dialog_controls_connect', 'attribute', {'name': 'value'}, 'connectdialog.connect');
  993. translatePiece('.connect-dialog.error-dialog .dialog-header', 'textcontent', {}, 'connectdialog.error.title');
  994. translatePiece('.connect-dialog.error-dialog .reason .refused', 'textcontent', {}, 'connectdialog.error.reason.refused');
  995. translatePiece('.connect-dialog.error-dialog .reason .version', 'textcontent', {}, 'connectdialog.error.reason.version');
  996. translatePiece('.connect-dialog.error-dialog .reason .username', 'textcontent', {}, 'connectdialog.error.reason.username');
  997. translatePiece('.connect-dialog.error-dialog .reason .userpassword', 'textcontent', {}, 'connectdialog.error.reason.userpassword');
  998. translatePiece('.connect-dialog.error-dialog .reason .serverpassword', 'textcontent', {}, 'connectdialog.error.reason.serverpassword');
  999. translatePiece('.connect-dialog.error-dialog .reason .username-in-use', 'textcontent', {}, 'connectdialog.error.reason.username_in_use');
  1000. translatePiece('.connect-dialog.error-dialog .reason .full', 'textcontent', {}, 'connectdialog.error.reason.full');
  1001. translatePiece('.connect-dialog.error-dialog .reason .clientcert', 'textcontent', {}, 'connectdialog.error.reason.clientcert');
  1002. translatePiece('.connect-dialog.error-dialog .reason .server', 'textcontent', {}, 'connectdialog.error.reason.server');
  1003. translatePiece('.connect-dialog.error-dialog .alternate-username', 'textcontent', {}, 'connectdialog.username');
  1004. translatePiece('.connect-dialog.error-dialog .alternate-password', 'textcontent', {}, 'connectdialog.password');
  1005. translatePiece('.connect-dialog.error-dialog .dialog-submit', 'attribute', {'name': 'value'}, 'connectdialog.error.retry');
  1006. translatePiece('.connect-dialog.error-dialog .dialog-close', 'attribute', {'name': 'value'}, 'connectdialog.error.cancel');
  1007. translatePiece('.join-dialog .dialog-header', 'textcontent', {}, 'joindialog.title');
  1008. translatePiece('.join-dialog .dialog-submit', 'attribute', {'name': 'value'}, 'joindialog.connect');
  1009. translatePiece('.user-context-menu .mute', 'textcontent', {}, 'usercontextmenu.mute');
  1010. translatePiece('.user-context-menu .deafen', 'textcontent', {}, 'usercontextmenu.deafen');
  1011. translatePiece('.user-context-menu .priority-speaker', 'textcontent', {}, 'usercontextmenu.priority_speaker');
  1012. translatePiece('.user-context-menu .local-mute', 'textcontent', {}, 'usercontextmenu.local_mute');
  1013. translatePiece('.user-context-menu .ignore-messages', 'textcontent', {}, 'usercontextmenu.ignore_messages');
  1014. translatePiece('.user-context-menu .view-comment', 'textcontent', {}, 'usercontextmenu.view_comment');
  1015. translatePiece('.user-context-menu .change-comment', 'textcontent', {}, 'usercontextmenu.change_comment');
  1016. translatePiece('.user-context-menu .reset-comment', 'textcontent', {}, 'usercontextmenu.reset_comment');
  1017. translatePiece('.user-context-menu .view-avatar', 'textcontent', {}, 'usercontextmenu.view_avatar');
  1018. translatePiece('.user-context-menu .change-avatar', 'textcontent', {}, 'usercontextmenu.change_avatar');
  1019. translatePiece('.user-context-menu .reset-avatar', 'textcontent', {}, 'usercontextmenu.reset_avatar');
  1020. translatePiece('.user-context-menu .send-message', 'textcontent', {}, 'usercontextmenu.send_message');
  1021. translatePiece('.user-context-menu .information', 'textcontent', {}, 'usercontextmenu.information');
  1022. translatePiece('.user-context-menu .self-mute', 'textcontent', {}, 'usercontextmenu.self_mute');
  1023. translatePiece('.user-context-menu .self-deafen', 'textcontent', {}, 'usercontextmenu.self_deafen');
  1024. translatePiece('.user-context-menu .add-friend', 'textcontent', {}, 'usercontextmenu.add_friend');
  1025. translatePiece('.user-context-menu .remove-friend', 'textcontent', {}, 'usercontextmenu.remove_friend');
  1026. translatePiece('.channel-context-menu .join', 'textcontent', {}, 'channelcontextmenu.join');
  1027. translatePiece('.channel-context-menu .add', 'textcontent', {}, 'channelcontextmenu.add');
  1028. translatePiece('.channel-context-menu .edit', 'textcontent', {}, 'channelcontextmenu.edit');
  1029. translatePiece('.channel-context-menu .remove', 'textcontent', {}, 'channelcontextmenu.remove');
  1030. translatePiece('.channel-context-menu .link', 'textcontent', {}, 'channelcontextmenu.link');
  1031. translatePiece('.channel-context-menu .unlink', 'textcontent', {}, 'channelcontextmenu.unlink');
  1032. translatePiece('.channel-context-menu .unlink-all', 'textcontent', {}, 'channelcontextmenu.unlink_all');
  1033. translatePiece('.channel-context-menu .copy-mumble-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_url');
  1034. translatePiece('.channel-context-menu .copy-mumble-web-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_web_url');
  1035. translatePiece('.channel-context-menu .send-message', 'textcontent', {}, 'channelcontextmenu.send_message');
  1036. }
  1037. async function main() {
  1038. await localizationInitialize(navigator.language);
  1039. translateEverything();
  1040. initializeUI();
  1041. initVoice(data => {
  1042. if (testVoiceHandler) {
  1043. testVoiceHandler.write(data)
  1044. }
  1045. if (!ui.client) {
  1046. if (voiceHandler) {
  1047. voiceHandler.end()
  1048. }
  1049. voiceHandler = null
  1050. } else if (voiceHandler) {
  1051. voiceHandler.write(data)
  1052. }
  1053. }, err => {
  1054. log(translate('logentry.mic_init_error'), err)
  1055. })
  1056. }
  1057. window.onload = main