Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

288 lines
8.3KB

  1. const isLandscape = screen.width > screen.height
  2. const State = {
  3. username: null,
  4. websocket: null,
  5. online: [],
  6. posts: [],
  7. rpcs: {},
  8. media: {},
  9. }
  10. const markedOptions = {
  11. breaks: true,
  12. }
  13. marked.setOptions(markedOptions)
  14. /*
  15. *
  16. * SIGNALING
  17. *
  18. */
  19. const wire = (message) => State.websocket.send(JSON.stringify(message))
  20. const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message}))
  21. const signalPeerStop = (username) => signal({kind: 'peerInfo', value: {type: 'stop'}, source: username})
  22. const listen = (kind, handler) => addEventListener(kind, handler)
  23. listen('login', ({detail}) => State.username = detail.value)
  24. listen('post', ({detail}) => State.posts.push(detail))
  25. listen('peerInfo', (e) => onPeerInfo(e))
  26. const doNotLog = new Set(['login', 'post', 'peerInfo'])
  27. /*
  28. *
  29. * ALERTS
  30. *
  31. */
  32. State.unseen = 0
  33. listen('post', () => {State.unseen += !document.hasFocus(); updateTitle()})
  34. listen('focus', () => {State.unseen = 0; updateTitle()})
  35. const updateTitle = () => {
  36. document.title = `Pico.Chat` + (State.unseen ? ` (${State.unseen})` : ``)
  37. }
  38. /*
  39. *
  40. * WEBRTC
  41. *
  42. */
  43. const getOrCreateRpc = (username) => {
  44. if(State.username === username) {
  45. return
  46. }
  47. const myStream = State.media[State.username]
  48. if(!State.rpcs[username] && myStream) {
  49. const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]})
  50. myStream.getTracks().forEach(track => rpc.addTrack(track, myStream))
  51. rpc.onicecandidate = ({candidate}) => {
  52. if(candidate) {
  53. wire({kind: 'peerInfo', value: {type: 'candidate', candidate}})
  54. }
  55. }
  56. rpc.ontrack = (e) => {
  57. State.media[username] = e.streams[0]
  58. m.redraw()
  59. }
  60. rpc.onclose = (e) => {
  61. console.log(username, e)
  62. }
  63. rpc.oniceconnectionstatechange = (e) => {
  64. m.redraw()
  65. }
  66. State.rpcs[username] = rpc
  67. }
  68. return State.rpcs[username]
  69. }
  70. const onPeerInfo = async ({detail: message}) => {
  71. const rpc = getOrCreateRpc(message.source)
  72. if(rpc && message.value.type === 'request') {
  73. const localOffer = await rpc.createOffer()
  74. await rpc.setLocalDescription(localOffer)
  75. wire({kind: 'peerInfo', value: localOffer, target: message.source})
  76. }
  77. else if(rpc && message.value.type === 'offer') {
  78. const remoteOffer = new RTCSessionDescription(message.value)
  79. await rpc.setRemoteDescription(remoteOffer)
  80. const localAnswer = await rpc.createAnswer()
  81. await rpc.setLocalDescription(localAnswer)
  82. wire({kind: 'peerInfo', value: localAnswer, target: message.source})
  83. }
  84. else if(rpc && message.value.type === 'answer') {
  85. const remoteAnswer = new RTCSessionDescription(message.value)
  86. await rpc.setRemoteDescription(remoteAnswer)
  87. }
  88. else if(rpc && message.value.type === 'candidate') {
  89. const candidate = new RTCIceCandidate(message.value.candidate)
  90. rpc.addIceCandidate(candidate)
  91. }
  92. else if(message.value.type === 'stop') {
  93. if(State.media[message.source]) {
  94. State.media[message.source].getTracks().map(track => track.stop())
  95. delete State.media[message.source]
  96. }
  97. if(State.rpcs[message.source]) {
  98. State.rpcs[message.source].close()
  99. delete State.rpcs[message.source]
  100. }
  101. }
  102. else if(rpc) {
  103. console.log('uncaught', message)
  104. }
  105. }
  106. /*
  107. *
  108. * GUI
  109. *
  110. */
  111. const autoFocus = (vnode) => {
  112. vnode.dom.focus()
  113. }
  114. const scrollIntoView = (vnode) => {
  115. vnode.dom.scrollIntoView()
  116. }
  117. const prettyTime = (ts) => {
  118. const dt = new Date(ts)
  119. const H = `0${dt.getHours()}`.slice(-2)
  120. const M = `0${dt.getMinutes()}`.slice(-2)
  121. const S = `0${dt.getSeconds()}`.slice(-2)
  122. return `${H}:${M}:${S}`
  123. }
  124. const hotKey = (e) => {
  125. // if isDesktop, Enter posts, unless Shift+Enter
  126. // use isLandscape as proxy for isDesktop
  127. if(e.key === 'Enter' && isLandscape && !e.shiftKey) {
  128. e.preventDefault()
  129. Chat.sendPost()
  130. }
  131. }
  132. const Video = {
  133. appendStream: ({username, stream}) => ({dom}) => {
  134. dom.autoplay = true
  135. dom.muted = (username === State.username)
  136. dom.srcObject = stream
  137. },
  138. view({attrs}) {
  139. const rpc = State.rpcs[attrs.username] || {iceConnectionState: m.trust(' ')}
  140. return m('.video-container',
  141. m('.video-source', attrs.username),
  142. m('.video-state', rpc.iceConnectionState),
  143. m('video', {playsinline: true, oncreate: Video.appendStream(attrs)}),
  144. )
  145. },
  146. }
  147. const Media = {
  148. audioVideo: {audio: true, video: {width: {ideal: 320}, facingMode: 'user'}},
  149. audioOnly: {audio: true, video: false},
  150. turnOn: (constraints) => async () => {
  151. const media = await navigator.mediaDevices.getUserMedia(constraints)
  152. State.media[State.username] = media
  153. wire({kind: 'peerInfo', value: {type: 'request'}})
  154. m.redraw()
  155. },
  156. turnOff: () => {
  157. wire({kind: 'peerInfo', value: {type: 'stop'}})
  158. State.online.forEach(signalPeerStop)
  159. },
  160. view() {
  161. if(!State.media[State.username]) {
  162. return m('.media',
  163. m('button', {onclick: Media.turnOn(Media.audioVideo)}, 'turn media on'),
  164. m('button', {onclick: Media.turnOn(Media.audioOnly)}, 'turn audio on'),
  165. )
  166. }
  167. else {
  168. return m('.media',
  169. m('button', {onclick: Media.turnOff}, 'turn media off'),
  170. m('.videos',
  171. Object.entries(State.media).map(([username, stream]) =>
  172. m(Video, {username, stream})
  173. ),
  174. ),
  175. )
  176. }
  177. }
  178. }
  179. const Login = {
  180. sendLogin: (e) => {
  181. e.preventDefault()
  182. const username = e.target.username.value
  183. localStorage.username = username
  184. connect(username)
  185. },
  186. sendLogout: (e) => {
  187. Media.turnOff()
  188. wire({kind: 'logout'})
  189. State.posts = []
  190. },
  191. view() {
  192. const attrs = {
  193. oncreate: autoFocus,
  194. name: 'username',
  195. autocomplete: 'off',
  196. value: localStorage.username,
  197. }
  198. return m('.login',
  199. m('form', {onsubmit: Login.sendLogin},
  200. m('input', attrs),
  201. m('button', 'Login'),
  202. ),
  203. m('.error', State.info),
  204. )
  205. },
  206. }
  207. const Chat = {
  208. sendPost: () => {
  209. if(textbox.value) {
  210. wire({kind: 'post', value: textbox.value})
  211. textbox.value = ''
  212. }
  213. },
  214. view() {
  215. return m('.chat',
  216. m('.posts',
  217. State.posts.map(post => m('.post', {oncreate: scrollIntoView},
  218. m('.ts', prettyTime(post.ts)),
  219. m('.source', post.source || '~'),
  220. m('.text', m.trust(DOMPurify.sanitize(marked(post.value)))),
  221. )),
  222. ),
  223. m('.actions',
  224. m('textarea#textbox', {oncreate: autoFocus, onkeydown: hotKey}),
  225. m('button', {onclick: Chat.sendPost}, 'Send'),
  226. ),
  227. m('.online',
  228. m('button', {onclick: Login.sendLogout}, 'Logout'),
  229. m('ul.user-list', State.online.map(username => m('li', username))),
  230. ),
  231. m(Media),
  232. )
  233. },
  234. }
  235. const Main = {
  236. view() {
  237. const connected = State.websocket && State.websocket.readyState === 1
  238. return connected ? m(Chat) : m(Login)
  239. },
  240. }
  241. m.mount(document.body, Main)
  242. /*
  243. *
  244. * WEBSOCKETS
  245. *
  246. */
  247. const connect = (username) => {
  248. const wsUrl = location.href.replace('http', 'ws')
  249. State.websocket = new WebSocket(wsUrl)
  250. State.websocket.onopen = (e) => {
  251. wire({kind: 'login', value: username})
  252. }
  253. State.websocket.onmessage = (e) => {
  254. const message = JSON.parse(e.data)
  255. if(message.online) {
  256. State.online = message.online
  257. }
  258. if(!doNotLog.has(message.kind)) {
  259. console.log(message)
  260. }
  261. signal(message)
  262. m.redraw()
  263. }
  264. State.websocket.onclose = (e) => {
  265. State.online.forEach(signalPeerStop)
  266. if(!e.wasClean) {
  267. setTimeout(connect, 1000, username)
  268. }
  269. m.redraw()
  270. }
  271. }
  272. if(localStorage.username) {
  273. connect(localStorage.username)
  274. }
  275. addEventListener('pagehide', Media.turnOff)