fwtoolkit 0.1.0-alpha.1 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/user.js ADDED
@@ -0,0 +1,46 @@
1
+ import {post} from "./network"
2
+
3
+ export const setLanguage = (_config, language) =>
4
+ post("/api/i18n/setlang/", {language}).then(() => {
5
+ // We delete the network cache as this contains the JS
6
+ // translations.
7
+ caches.keys().then(names => {
8
+ for (const name of names) {
9
+ caches.delete(name)
10
+ }
11
+ window.location.reload()
12
+ })
13
+ })
14
+
15
+ const COLOR_CACHE = {}
16
+
17
+ const userColor = string => {
18
+ // Source https://gist.github.com/0x263b/2bdd90886c2036a1ad5bcf06d6e6fb37
19
+ if (string.length === 0) {
20
+ return "rgb(0,0,0)"
21
+ } else if (COLOR_CACHE[string]) {
22
+ return COLOR_CACHE[string]
23
+ }
24
+ let hash = 0
25
+ for (let i = 0; i < string.length; i++) {
26
+ hash = string.charCodeAt(i) + ((hash << 5) - hash)
27
+ hash = hash & hash
28
+ }
29
+ const rgb = [0, 0, 0]
30
+ for (let i = 0; i < 3; i++) {
31
+ rgb[i] = (hash >> (i * 8)) & 255
32
+ }
33
+ COLOR_CACHE[string] = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`
34
+ return COLOR_CACHE[string]
35
+ }
36
+
37
+ /** A template for the default round avatar view. */
38
+ export const avatarTemplate = ({user}) => {
39
+ const name = user.username || user.name || "A"
40
+ if (user.avatar) {
41
+ return `<img class="fw-avatar" src="${user.avatar}" alt="${name}">`
42
+ } else {
43
+ const color = userColor(name)
44
+ return `<span class="fw-string-avatar" style="background-color: ${color};"><span>${name[0]}</span></span>`
45
+ }
46
+ }
@@ -0,0 +1,16 @@
1
+ /** Creates a dropdown box.
2
+ * @param btn The button to open and close the dropdown box.
3
+ * @param box The node containing the contents of the dropdown box.
4
+ * @param preopen An optional function to be called before opening the dropdown box. Used to position dropdown box.
5
+ */
6
+ export const filterPrimaryEmail = emails => {
7
+ const primaryEmails = emails.filter(email => email.primary)
8
+ if (!primaryEmails.length) {
9
+ if (emails.length) {
10
+ return emails[0].address
11
+ } else {
12
+ return ""
13
+ }
14
+ }
15
+ return primaryEmails[0].address
16
+ }
package/src/worker.js ADDED
@@ -0,0 +1,12 @@
1
+ /* allows cross domain web workers */
2
+ /* Taken from https://benohead.com/cross-domain-cross-browser-web-workers/ */
3
+ export const makeWorker = workerUrl => {
4
+ const a = document.createElement("a")
5
+ a.href = workerUrl // turn into absolute URL if needed.
6
+ const blob = new Blob([`importScripts("${a.href}")`], {
7
+ type: "application/javascript"
8
+ }),
9
+ blobUrl = window.URL.createObjectURL(blob),
10
+ worker = new Worker(blobUrl)
11
+ return worker
12
+ }
package/src/ws.js ADDED
@@ -0,0 +1,347 @@
1
+ /* Sets up communicating with server (retrieving document, saving, collaboration, etc.).
2
+ */
3
+ export class WebSocketConnector {
4
+ constructor({
5
+ base = "", // needs to be specified
6
+ path = "", // needs to be specified
7
+ appLoaded = () => false, // required argument
8
+ anythingToSend = () => false, // required argument
9
+ messagesElement = () => false, // element in which to show connection messages
10
+ initialMessage = () => ({type: "subscribe"}),
11
+ resubScribed = () => {}, // Cleanup when the client connects a second or subsequent time
12
+ restartMessage = () => ({type: "restart"}), // Too many messages have been lost and we need to restart
13
+ warningNotAllSent = gettext("Warning! Some data is unsaved"), // Info to show while disconnected WITH unsaved data
14
+ infoDisconnected = gettext("Disconnected. Attempting to reconnect..."), // Info to show while disconnected WITHOUT unsaved data
15
+ receiveData = _data => {},
16
+ failedAuth = () => {
17
+ window.location.href = "/"
18
+ }
19
+ }) {
20
+ this.base = base
21
+ this.path = path
22
+ this.appLoaded = appLoaded
23
+ this.anythingToSend = anythingToSend
24
+ this.messagesElement = messagesElement
25
+ this.initialMessage = initialMessage
26
+ this.resubScribed = resubScribed
27
+ this.restartMessage = restartMessage
28
+ this.warningNotAllSent = warningNotAllSent
29
+ this.infoDisconnected = infoDisconnected
30
+ this.receiveData = receiveData
31
+ this.failedAuth = failedAuth
32
+ /* A list of messages to be sent. Only used when temporarily offline.
33
+ Messages will be sent when returning back online. */
34
+ this.messagesToSend = []
35
+ /* A list of messages from a previous connection */
36
+ this.oldMessages = []
37
+
38
+ this.online = true
39
+ this.connected = false
40
+ /* Increases when connection has to be reestablished */
41
+ /* 0 = before first connection. */
42
+ /* 1 = first connection established, etc. */
43
+ this.connectionCount = 0
44
+ this.recentlySent = false
45
+ this.listeners = {}
46
+
47
+ //heartbeat
48
+ this.pingTimer = false
49
+ this.pongTimer = false
50
+ }
51
+
52
+ init() {
53
+ this.createWSConnection()
54
+
55
+ // Close the socket manually for now when the connection is lost. Sometimes the socket isn't closed on disconnection.
56
+ this.listeners.onOffline = _event => this.ws.close()
57
+ window.addEventListener("offline", this.listeners.onOffline)
58
+ }
59
+
60
+ goOffline() {
61
+ // Simulate offline mode due to lack of ways of doing this in Chrome/Firefox
62
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1421357
63
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=423246
64
+ this.online = false
65
+ this.connected = false
66
+ this.ws.close()
67
+ }
68
+
69
+ goOnline() {
70
+ // Reconnect from offline mode
71
+ this.online = true
72
+ }
73
+
74
+ close() {
75
+ if (this.ws) {
76
+ this.ws.onclose = () => {}
77
+ this.ws.close()
78
+ }
79
+ window.removeEventListener("offline", this.listeners.onOffline)
80
+ }
81
+
82
+ createWSConnection() {
83
+ // Messages object used to ensure that data is received in right order.
84
+ this.messages = {
85
+ server: 0,
86
+ client: 0,
87
+ lastTen: []
88
+ }
89
+ let url
90
+ if (this.online) {
91
+ if (this.base.startsWith("/")) {
92
+ url = this.base + this.path
93
+ } else if (location.protocol === "https:") {
94
+ url = `wss://${this.base}${this.path}`
95
+ } else {
96
+ url = `ws://${this.base}${this.path}`
97
+ }
98
+ } else {
99
+ if (location.protocol === "https:") {
100
+ url = "wss://offline"
101
+ } else {
102
+ url = "ws://offline"
103
+ }
104
+ }
105
+ if (this.ws) {
106
+ this.ws.onmessage = () => {}
107
+ this.ws.onclose = () => {}
108
+ this.ws.close()
109
+ }
110
+ this.ws = new window.WebSocket(url)
111
+ this.ws.onmessage = event => this.onmessage(event)
112
+ this.ws.onclose = () => this.onclose()
113
+ }
114
+
115
+ waitForWS() {
116
+ return new Promise((resolve, reject) => {
117
+ const checkState = () => {
118
+ if (!this.ws) {
119
+ // WebSocket doesn't exist
120
+ return setTimeout(() => checkState(), 100)
121
+ }
122
+
123
+ if (this.ws.readyState === this.ws.OPEN) {
124
+ // WebSocket is open and ready
125
+ return resolve()
126
+ } else if (this.ws.readyState === this.ws.CONNECTING) {
127
+ // WebSocket is still connecting, wait
128
+ return setTimeout(() => checkState(), 100)
129
+ } else {
130
+ // WebSocket is in CLOSING or CLOSED state
131
+ // We should not try to send on this socket
132
+ return reject(new Error("WebSocket is not in OPEN state"))
133
+ }
134
+ }
135
+
136
+ checkState()
137
+ })
138
+ }
139
+
140
+ onmessage(event) {
141
+ const data = JSON.parse(event.data)
142
+ const expectedServer = this.messages.server + 1
143
+ if (data.type === "request_resend") {
144
+ this.resend_messages(data.from)
145
+ } else if (data.type === "pong") {
146
+ this.heartbeat()
147
+ } else if (data.s < expectedServer) {
148
+ // Receive a message already received at least once. Ignore.
149
+ return
150
+ } else if (data.s > expectedServer) {
151
+ // Messages from the server have been lost.
152
+ // Request resend.
153
+ this.waitForWS()
154
+ .then(() =>
155
+ this.ws.send(
156
+ JSON.stringify({
157
+ type: "request_resend",
158
+ from: this.messages.server
159
+ })
160
+ )
161
+ )
162
+ .catch(() => {
163
+ // Connection not ready, we will handle this on reconnection
164
+ })
165
+ } else {
166
+ this.messages.server = expectedServer
167
+ if (data.c === this.messages.client) {
168
+ this.receive(data)
169
+ } else if (data.c < this.messages.client) {
170
+ // We have received all server messages, but the server seems
171
+ // to have missed some of the client's messages. They could
172
+ // have been sent simultaneously.
173
+ // The server wins over the client in this case.
174
+ this.waitForWS().then(() => {
175
+ const clientDifference = this.messages.client - data.c
176
+ this.messages.client = data.c
177
+ if (clientDifference > this.messages.lastTen.length) {
178
+ // We cannot fix the situation
179
+ this.send(this.restartMessage)
180
+ return
181
+ }
182
+ this.messages["lastTen"]
183
+ .slice(0 - clientDifference)
184
+ .forEach(data => {
185
+ this.messages.client += 1
186
+ data.c = this.messages.client
187
+ data.s = this.messages.server
188
+
189
+ this.ws.send(JSON.stringify(data))
190
+ })
191
+ this.receive(data)
192
+ })
193
+ }
194
+ }
195
+ }
196
+
197
+ onclose() {
198
+ this.connected = false
199
+ window.setTimeout(() => {
200
+ this.createWSConnection()
201
+ }, 2000)
202
+ if (!this.appLoaded()) {
203
+ // doc not initiated
204
+ return
205
+ }
206
+
207
+ const messagesElement = this.messagesElement()
208
+ if (messagesElement) {
209
+ if (this.anythingToSend()) {
210
+ messagesElement.innerHTML = `<span class="warn">${this.warningNotAllSent}</span>`
211
+ } else {
212
+ messagesElement.innerHTML = this.infoDisconnected
213
+ }
214
+ }
215
+ }
216
+
217
+ open() {
218
+ const messagesElement = this.messagesElement()
219
+ if (messagesElement) {
220
+ messagesElement.innerHTML = ""
221
+ }
222
+ this.connected = true
223
+
224
+ const message = this.initialMessage()
225
+ this.connectionCount++
226
+ this.oldMessages = this.messagesToSend
227
+ this.messagesToSend = []
228
+
229
+ this.send(() => message)
230
+ }
231
+
232
+ subscribed() {
233
+ if (this.connectionCount > 1) {
234
+ this.resubScribed()
235
+ }
236
+ while (this.oldMessages.length > 0) {
237
+ this.send(this.oldMessages.shift())
238
+ }
239
+ }
240
+
241
+ /** Sends data to server or keeps it in a list if currently offline. */
242
+ send(getData, timer = 80) {
243
+ if (this.connected && this.ws.readyState !== this.ws.OPEN) {
244
+ this.ws.close()
245
+ return
246
+ }
247
+ if (this.connected && !this.recentlySent) {
248
+ const data = getData()
249
+ if (!data) {
250
+ // message is empty
251
+ return
252
+ }
253
+ this.messages.client += 1
254
+ data.c = this.messages.client
255
+ data.s = this.messages.server
256
+ this.messages.lastTen.push(data)
257
+ this.messages.lastTen = this.messages["lastTen"].slice(-10)
258
+
259
+ this.waitForWS()
260
+ .then(() => {
261
+ this.ws.send(JSON.stringify(data))
262
+ this.setRecentlySentTimer(timer)
263
+ })
264
+ .catch(() => {
265
+ // Failed to send - likely WebSocket is not open
266
+ // Put the message back into the queue
267
+ this.messages.client -= 1
268
+ this.messagesToSend.unshift(getData)
269
+ // Remove from lastTen to avoid duplicates
270
+ this.messages.lastTen = this.messages.lastTen.slice(0, -1)
271
+ })
272
+ } else {
273
+ this.messagesToSend.push(getData)
274
+ }
275
+ }
276
+
277
+ setRecentlySentTimer(timer) {
278
+ this.recentlySent = true
279
+ window.setTimeout(() => {
280
+ this.recentlySent = false
281
+ const oldMessages = this.messagesToSend
282
+ this.messagesToSend = []
283
+ while (oldMessages.length > 0) {
284
+ const getData = oldMessages.shift()
285
+ this.send(getData, Math.min(timer * 1.2, 10000))
286
+ }
287
+ }, timer)
288
+ }
289
+
290
+ resend_messages(from) {
291
+ return this.waitForWS()
292
+ .then(() => {
293
+ const toSend = this.messages.client - from
294
+ this.messages.client = from
295
+ if (toSend > this.messages.lastTen.length) {
296
+ // Too many messages requested. Abort.
297
+ this.send(this.restartMessage)
298
+ return
299
+ }
300
+ this.messages.lastTen.slice(0 - toSend).forEach(data => {
301
+ this.messages.client += 1
302
+ data.c = this.messages.client
303
+ data.s = this.messages.server
304
+ this.ws.send(JSON.stringify(data))
305
+ })
306
+ })
307
+ .catch(() => {
308
+ // Could not send messages - WebSocket not ready
309
+ // Will try again when WebSocket is ready
310
+ })
311
+ }
312
+
313
+ receive(data) {
314
+ switch (data.type) {
315
+ case "redirect":
316
+ this.base = data.base
317
+ break
318
+ case "welcome":
319
+ this.open()
320
+ break
321
+ case "subscribed":
322
+ this.subscribed()
323
+ this.heartbeat()
324
+ break
325
+ case "access_denied":
326
+ this.failedAuth()
327
+ break
328
+ default:
329
+ this.receiveData(data)
330
+ break
331
+ }
332
+ }
333
+
334
+ heartbeat() {
335
+ clearTimeout(this.pingTimer)
336
+ clearTimeout(this.pongTimer)
337
+ this.pingTimer = setTimeout(() => {
338
+ // Don't send ping if WebSocket is not open
339
+ if (this.ws.readyState === this.ws.OPEN) {
340
+ this.ws.send('{"type": "ping"}')
341
+ this.pongTimer = setTimeout(() => {
342
+ this.listeners.onOffline()
343
+ }, 10000)
344
+ }
345
+ }, 60000)
346
+ }
347
+ }
package/src/file.js DELETED
@@ -1,25 +0,0 @@
1
- import {escapeText} from "./text.js"
2
-
3
- export const shortFileTitle = (title, path) => {
4
- if (!path.length || path.endsWith("/")) {
5
- return escapeText(title || gettext("Untitled"))
6
- }
7
- return escapeText(path.split("/").pop())
8
- }
9
-
10
- export const longFilePath = (title, path, prefix = "") => {
11
- if (!path.length) {
12
- path = "/"
13
- }
14
- if (path.endsWith("/")) {
15
- path += title.replace(/\//g, "") || gettext("Untitled")
16
- }
17
- if (prefix.length) {
18
- const pathParts = path.split("/")
19
- const fileName = pathParts.pop()
20
- pathParts.push(prefix + fileName)
21
- path = pathParts.join("/")
22
- }
23
-
24
- return path
25
- }
package/src/text.js DELETED
@@ -1,44 +0,0 @@
1
- export const escapeText = text => {
2
- if (typeof text !== "string") {
3
- return String(text)
4
- }
5
- return text
6
- .replace(/&/g, "&amp;")
7
- .replace(/</g, "&lt;")
8
- .replace(/>/g, "&gt;")
9
- .replace(/"/g, "&quot;")
10
- .replace(
11
- /[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]/g,
12
- ""
13
- ) // invalid in XML chars
14
- }
15
-
16
- export const unescapeText = text =>
17
- text
18
- .replace(/&lt;/g, "<")
19
- .replace(/&gt;/g, ">")
20
- .replace(/&quot;/g, '"')
21
- .replace(/&amp;/g, "&")
22
-
23
- /**
24
- * Turn string literals into single line, removing spaces at start of line
25
- */
26
- export const noSpaceTmp = (strings, ...values) => {
27
- const tmpStrings = Array.from(strings)
28
-
29
- let combined = ""
30
- while (tmpStrings.length > 0 || values.length > 0) {
31
- if (tmpStrings.length > 0) {
32
- combined += tmpStrings.shift()
33
- }
34
- if (values.length > 0) {
35
- combined += values.shift()
36
- }
37
- }
38
-
39
- let out = ""
40
- combined.split("\n").forEach(line => {
41
- out += line.replace(/^\s*/g, "")
42
- })
43
- return out
44
- }