Porting apps to webxdc: don't just fork, make MRs

There are some tens of webxdc apps already. A lot of them are forks (see DeltaZen · GitHub and webxdc · GitHub). I think we should strive for getting the porting changes merged into the upstream repos.
Benefits:

  • Greater exposure of webxdc. More people are gonna find out about it, by just using the app / visiting its page.
  • Easier maintenance. Maintaining one copy of the project is easier than maintaining 2. And if whoever ported the app gets hit by a bus, the project will live on.

Although the majority of the upstream repos I mentioned above didn’t get updated since they were forked. So what I’m suggesting is of course not always a necessity. But I hope I make the general idea clear.

The project owners may of course push back on it, since the porting may introduce additional complications, but this needs to be taken also as feedback, an opportunity for improvement. Developing for webxdc needs to be simple. The ultimate goal after all is to make people want to make webxdc apps themselves.

1 Like

Another benefit: people looking at existing apps can see that they don’t have to keep two separate versions of their app (the original one and the webxdc one).

I would appreciate that. I checked the text editor and checklist apps ignoring .git, README.md, package.json, package-lock.json and icon.png. The complete diff including additions and substractions is about 300 lines. That is confusing for new users. And as you said @WofWca it could be easier to maintain.

editors.diff (238 lines)
diff -r editor/css/main.css editorP2p/css/main.css
533a534,572
> 
> /* Prose Mirror cursor styling 
>    https://github.com/yjs/y-prosemirror?tab=readme-ov-file#remote-cursors
> */
> /* this is a rough fix for the first cursor position when the first paragraph is empty */
> .ProseMirror > .ProseMirror-yjs-cursor:first-child {
>   margin-top: 16px;
> }
> .ProseMirror p:first-child, .ProseMirror h1:first-child, .ProseMirror h2:first-child, .ProseMirror h3:first-child, .ProseMirror h4:first-child, .ProseMirror h5:first-child, .ProseMirror h6:first-child {
>   margin-top: 16px
> }
> /* This gives the remote user caret. The colors are automatically overwritten*/
> .ProseMirror-yjs-cursor {
>   position: relative;
>   margin-left: -1px;
>   margin-right: -1px;
>   border-left: 1px solid black;
>   border-right: 1px solid black;
>   border-color: orange;
>   word-break: normal;
>   pointer-events: none;
> }
> /* This renders the username above the caret */
> .ProseMirror-yjs-cursor > div {
>   position: absolute;
>   top: -1.05em;
>   left: -1px;
>   font-size: 13px;
>   background-color: rgb(250, 129, 0);
>   font-family: serif;
>   font-style: normal;
>   font-weight: normal;
>   line-height: normal;
>   user-select: none;
>   color: white;
>   padding-left: 2px;
>   padding-right: 2px;
>   white-space: nowrap;
> }
diff -r editor/src/main.mjs editorP2p/src/main.mjs
3c3,157
< import WebxdcProvider from 'y-webxdc';
---
> 
> import { applyUpdateV2, mergeUpdatesV2 } from 'yjs';
> import { fromUint8Array, toUint8Array } from 'js-base64';
> 
> import * as awarenessProtocol from 'y-protocols/awareness';
> 
> import { getColorForFirstCharacter } from 'color-generator-fl';
> 
> class WebxdcProvider {
>     constructor({ webxdc, ydoc, awareness, getEditInfo, autosaveInterval }) {
>         this.webxdc = webxdc;
>         this.ydoc = ydoc;
>         this.getEditInfo = getEditInfo;
>         this.awareness = awareness;
> 
>         // Set the user's name in the awarenessProtocol
>         const color = getColorForFirstCharacter(webxdc.selfName);
>         awareness.setLocalStateField('user', { color: color, name: webxdc.selfName })
> 
>         // queued yjs-updates, to be flushed and sent out in syncToChatPeers()
>         this.queuedYjsUpdates = [];
>         this.everNotifiedPeersAboutEdits = false;
> 
>         // call 'sync' handlers with {hasQueued: true|false} on queue changes
>         this.eventHandlers = { sync: [] };
>         this.on = (name, handler) => {
>             this.eventHandlers[name].push(handler);
>         };
> 
>         // Set listeners for both the normal channel and real time channels.
>         if(this.webxdc.joinRealtimeChannel !== undefined) {
>             awareness.on('change', () => this.syncToChatPeers(true));
>             this.realtimeChannel = this.webxdc.joinRealtimeChannel();
>             this.realtimeChannel.setListener(payload => 
>                 this.receiveRealtimeWebxdcUpdateFromChatPeers(payload)
>             );
>         }
> 
>         webxdc.setUpdateListener(update => 
>             this.receiveWebxdcUpdateFromChatPeers(update)
>         );
> 
>         // setup automatic webxdc/yjs IO synchronization
>         ydoc.on('updateV2', (yjsupdate, origin) => this.receiveYjsUpdate(yjsupdate, origin));
>         registerExitHandlerForWebxdcWindow(() => this.syncToChatPeers());
> 
>         setInterval(() => this.syncToChatPeers(false), autosaveInterval);
>     }
> 
>     syncToChatPeers(isRealTime) {
>         const { document, summary, startinfo } = this.getEditInfo();
>         const mergedYjsUpdate = mergeUpdatesV2(this.queuedYjsUpdates);
> 
>         // The payload contains:
>         // - The updates in the document itself
>         // - The updates of the cursor and who's currently online
>         const payload = { 
>             serializedYjsUpdate: fromUint8Array(mergedYjsUpdate),
>             serializedAwarenessUpdate: fromUint8Array(awarenessProtocol.encodeAwarenessUpdate(this.awareness, Array.from(this.awareness.getStates().keys()))) 
>         };
> 
>         const update = { payload, document, summary };
> 
>         // If it's realtime, we send the updates immediately
>         // We exit early so that we don't send webxdc.sendUpdate
>         if(isRealTime) {
>             this.realtimeChannel.send(new TextEncoder().encode(JSON.stringify(update.payload)));
>             return;
>         }
> 
>         // Make sure we update the 
>         if (!this.everNotifiedPeersAboutEdits) {
>             update.info = startinfo;
>             this.everNotifiedPeersAboutEdits = true;
>         }
> 
>         // If we're in the normal channel, send updates only as needed
>         // to prevent being rate limited.
>         if (this.queuedYjsUpdates.length > 0) {
>             this.webxdc.sendUpdate(update, 'document edited');
>             
>             this.queuedYjsUpdates.length = 0;
>             this.eventHandlers.sync.map((func) => func({hasQueued: false}));
>         }
>     }
> 
>     receiveWebxdcUpdateFromChatPeers(update) {
>         const serialized = update.payload.serializedYjsUpdate;
>         if (serialized) {
>             const ydoc = this.ydoc;
>             applyUpdateV2(ydoc, toUint8Array(serialized), ydoc.clientID);
>         }
>     }
> 
>     receiveRealtimeWebxdcUpdateFromChatPeers(payload) {
>         payload = JSON.parse(new TextDecoder().decode((payload)));
>         awarenessProtocol.applyAwarenessUpdate(this.awareness, toUint8Array(payload.serializedAwarenessUpdate), this);
> 
>         const serialized = payload.serializedYjsUpdate;
>         if (serialized) {
>             const ydoc = this.ydoc;
>             applyUpdateV2(ydoc, toUint8Array(serialized), ydoc.clientID);
>         }
>     }
> 
>     receiveYjsUpdate(yjsUpdate, origin) {
>         if (origin === this.ydoc.clientID) {
>             return;
>         }
>         this.queuedYjsUpdates.push(yjsUpdate);
>         this.eventHandlers.sync.map((func) => func({hasQueued: true}));
> 
>         // Send real time update if available
>         // We send it immeditately instead of queuing it.
>         if(this.webxdc.joinRealtimeChannel !== undefined) {
>             this.syncToChatPeers(true);
>         }
>     }
> }
> 
> function registerExitHandlerForWebxdcWindow(finalize) {
>     // On Android and iOS visibilitychange handlers are reliably called 
>     window.addEventListener('visibilitychange', () => {
>         if (document.visibilityState !== 'hidden') {
>             return;
>         }
>         finalize();
>     });
> 
>     // Desktop only executes beforeunload handlers on windows close. 
>     window.addEventListener('beforeunload', sendUpdatesBeforeUnload);
> 
>     function sendUpdatesBeforeUnload(beforeUnloadEvent) {
>         // Desktop does not execute webxdc.sendUpdate() synchronously, see
>         // https://github.com/deltachat/deltachat-desktop/issues/3321#issue-1814659377
>         // The workaround is to prevent closing in beforeunload handler and close
>         // the window ourselves after a small timeout. 
>         window.removeEventListener('beforeunload', sendUpdatesBeforeUnload);
>         finalize();
>         setTimeout(() => {
>             // We close the parent, relying on running inside an `<iframe>`
>             // which is the case for Delta Chat Desktop.
>             try {
>                 window.parent.close();
>             } catch (e) {
>                 window.close();
>             }
>         }, 100 /* milliseconds */ );
> 
>         beforeUnloadEvent.preventDefault();
>         // For iOS Safari compatibility as of Sept 2023
>         beforeUnloadEvent.returnValue = '';
>         return '';
>     }
> }
11,13c165,166
<   const editorView = getProsemirrorEditorWithSync(ydoc, elementId, webxdc.sendToChat);
< 
<   let autosaveInterval = webxdc.sendUpdateInterval || 5 * 1000;
---
>   const awareness = new awarenessProtocol.Awareness(ydoc);
>   const editorView = getProsemirrorEditorWithSync(ydoc, elementId, webxdc.sendToChat, awareness);
18c171,172
<     autosaveInterval: autosaveInterval,
---
>     awareness: awareness,
>     autosaveInterval: 5*1000,
25a180
> 
diff -r editor/src/prosemirror-setup.mjs editorP2p/src/prosemirror-setup.mjs
24c24,25
<   yUndoPlugin
---
>   yUndoPlugin,
>   yCursorPlugin
31,32c32
< 
< export function getProsemirrorEditorWithSync(ydoc, elementId, sendToChat) {
---
> export function getProsemirrorEditorWithSync(ydoc, elementId, sendToChat, awareness) {
35c35
<   const prosemirrorSetup = getProsemirrorSetup(frag, syncId, sendToChat);
---
>   const prosemirrorSetup = getProsemirrorSetup(frag, syncId, sendToChat, awareness);
42a43
> 
47c48,49
< function getProsemirrorSetup(xmlfrag, syncId, sendToChat) {
---
> function getProsemirrorSetup(xmlfrag, syncId, sendToChat, awareness) {
>   console.log(awareness);
149a152
>         yCursorPlugin(awareness),
checklists.diff (444 lines)
diff -r checklist/index.html checklistP2P/index.html
11c11
< <body>
---
> <body style="opacity: 0;">
13,21c13,27
<   <div>
<     <div id="MainMenu" onclick="AppBehaviour.ControlOpenMenu()">⋮<div id="MenuItems" class="hidden">
<       <button onclick="AppBehaviour.ControlEditTitle(); event.stopPropagation()">Edit title</button>
<       <button onclick="AppBehaviour.ControlImport(); event.stopPropagation()">Import file</button>
<       <button onclick="AppBehaviour.ControlExport(); event.stopPropagation()">Export file</button>
<       <button onclick="AppBehaviour.ControlCleanup(); event.stopPropagation()">Cleanup completed</button>
<     </div></div>
<     <h1 id="AppHeading" class="AppHeading">Checklist</h1>
<     <button id="AppSaveTitleButton" style="display: none;" onclick="AppBehaviour.ControlSaveTitle()" type="button">Save</button>
---
>   <div class="header">
>     <div id="MainMenu" onclick="AppBehaviour.ControlOpenMenu()">
>       <div class="menu-icon">
>         <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" width="28" stroke-width="1.5" stroke="currentColor" class="size-6">
> 	<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
>         </svg>
>       </div>
>       <div id="MenuItems">
>         <button onclick="AppBehaviour.ControlEditTitle(); event.stopPropagation()">Edit title</button>
>         <button onclick="AppBehaviour.ControlImport(); event.stopPropagation()">Import file</button>
>         <button onclick="AppBehaviour.ControlExport(); event.stopPropagation()">Export file</button>
>         <button onclick="AppBehaviour.ControlCleanup(); event.stopPropagation()" class="button-destructive">Delete completed</button>
>       </div>
>     </div>
>     <h1 id="AppHeading" class="AppHeading" onclick="AppBehaviour.ControlEditTitle(); event.stopPropagation()">Checklist</h1>
25,26c31,32
<     <input id="AppCreateField" class="AppListItemField" type="text" placeholder="Item title" maxlength="200" autofocus required />
<     <input class="AppCreateButton AppListItemButton" type="submit" onclick="AppBehaviour.ControlCreate(window.AppCreateField.value); return false;" value="Add Item" />
---
>     <input id="AppCreateField" class="AppListItemField" type="text" placeholder="" maxlength="200" autofocus required />
>     <input class="AppCreateButton AppListItemButton" type="submit" onclick="AppBehaviour.ControlCreate(window.AppCreateField.value); return false;" value="Add item" />
32d37
< 
diff -r checklist/main.css checklistP2P/main.css
1a2,14
>   /* Colors */
>   --grey-01: #f8f9fa;
>   --grey-02: #e9ecef;
>   --grey-03: #dee2e6;
>   --grey-04: #ced4da;
>   --grey-05: #adb5bd;
>   --grey-06: #495057;
>   --grey-07: #343a40;
>   --grey-08: #212529;
>   --red: #c1121f;
> 
>   /* Global styling */
>   --system-ui: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
3,4c16,59
<   color: white;
<   background: #1399ff;
---
>   --background-color: var(--grey-01);
>   --text-color: var(--grey-08);
> 
>   /* Menu */
>   --menu-background-color: white;
>   --menu-main-color: var(--grey-08);
> 
>   /* Text input */
>   --input-text-background-color: var(--grey-01);
>   --input-text-height: 2.5em;
> 
>   /* List */
>   --item-separator-color: var(--grey-02);
>   --item-done-color: var(--grey-05);
> 
>   /* Create / edit task input */
>   --create-field-outline: var(--grey-04);
>   --create-field-outline-focus: var(--grey-05);
>   --create-field-background: var(--input-text-background-color);
> 
>   /* Buttons */
>   --button-background: white;
>   --button-destructive-color: var(--red);
> }
> 
> @media (prefers-color-scheme: dark) {
>   :root {
>     --background-color: var(--grey-08);    
>     --text-color: var(--grey-01);
> 
>     --menu-main-color: var(--grey-01);
>     --menu-background-color: var(--grey-07);
> 
>     --button-background: var(--grey-08);
>     --item-separator-color: var(--grey-06);
> 
>     --create-field-outline: var(--grey-06);
>     --create-field-outline-focus: var(--grey-05);
>   }
> }
> 
> html {
>   height: 100vh;
>   background: var(--background-color);
8,9c63,75
<   font-family: Helvetica, Arial, sans-serif;
<   font-size: 18px;
---
>   font-family: var(--system-ui);
>   background: var(--background-color);
>   color: var(--text-color);
>   padding: 1em;
>   transition: opacity .3s ease-in-out;
> }
> 
> button {
>   color: var(--text-color);
> }
> 
> input[type="submit"] {
>   color: var(--text-color);
14,15c80,81
<   color: #fff;
<   font-size: 28px;
---
>   color: var(--menu-main-color);
>   font-size: 1.7em;
17c83,88
<   padding: 0 7px;
---
> }
> 
> .menu-icon {
>   display: flex;
>   align-items: center;
>   cursor: pointer;
23c94
<   background-color: #f9f9f9;
---
>   background-color: var(--menu-background-color);
26a98,113
>   border-radius: var(--AppPadding); 
> 
>   display: none;
>   opacity: 0;
>   transition: opacity .2s ease-in-out, display .2s ease-in-out allow-discrete;
> }
> 
> #MenuItems.showing {
>   opacity: 1;
>   display: block;
> }
> 
> @starting-style {
>   #MenuItems.showing {
>     opacity: 0;
>   }
33c120
<   color: #111;
---
>   color: var(--menu-main-color);
41,44d127
< #MenuItems button:hover {
<   background-color: #e0e0e0;
< }
< 
56c139
<   display: inline-block;
---
>   display: flex;
59,60c142
<   padding-right: 0.5rem;
<   font-size: 30px;
---
>   margin: 0 0 0 0.6em;
61a144,146
>   font-size: 1.7em;
>   align-items: center;
>   width: 100%;
65c150
<   padding: 2px;
---
>   padding: 0px;
70d154
< 
73c157,158
<   margin-bottom: 4px;
---
>   padding: 1em 0;
>   border-bottom: 1px solid var(--item-separator-color);
77,79c162,163
<   width: 1rem;
<   height: 1rem;
<   margin-right: 6px;
---
>   transform: scale(1.5) translate(2px, 0px);
>   margin: 0;
83c167,168
<   padding: 0.375rem;
---
>   width: 100%;
>   padding-left: 1.5em;
88c173
<   color: #b3e3ff;
---
>   color: var(--item-done-color);
93c178,179
<   padding-left: 6px;
---
>   width: 100%;
>   height: var(--input-text-height);
96,99c182,186
< .AppListItemField, .AppListItemButton {
<   padding: var(--AppPadding);
<   border: 1px solid black;
<   border-radius: var(--AppPadding);
---
> .header {
>   display: flex;
>   padding-bottom: 1em;
>   align-items: center;
> }
101c188,189
<   margin: 0;
---
> .AppCreate > input {
>   border: none;
104,105c192,199
< #AppCreateField, .AppCreateButton {
<   font-size: 12px;
---
> .AppListItemField, .AppListItemButton {
>   padding: var(--AppPadding) 10px;
>   border: 0;
>   border-radius: var(--AppPadding);
>   outline: solid 1px var(--create-field-outline);
>   margin: 0;
>   background: var(--button-background);
>   color: var(--text-color);
109c203
<   border-right: none;
---
>   width: 100%;
110a205,214
>   outline: solid 1px var(--create-field-outline);
>   font-size: 1em;
> }
> 
> .AppListItemField:focus {
>   outline: solid 1px var(--create-field-outline-focus);
> }
> 
> .AppListItemField:focus + .AppCreateButton {
>   outline: solid 1px var(--create-field-outline-focus);
115,117c219,246
<   
<   background: black;
<   background: lightgray;
---
>   background: var(--button-background);
>   outline: solid 1px var(--create-field-outline);
> }
> 
> .AppCreateButton {
>   width: 95px;
> }
> 
> .AppListItemUpdateButton {
>   border-radius: 0 var(--AppPadding) var(--AppPadding) 0;
>   width: 95px;
> }
> 
> .AppListItemUpdateField {
>   font-size: 1em;
> }
> 
> /* Add outline to sibling when the input box is focused */
> .AppListItemField ~ .AppListClearButton {
>   outline: solid 1px var(--create-field-outline);
> }
> 
> .AppListItemField:focus ~ .AppListClearButton {
>   outline: solid 1px var(--create-field-outline-focus);
> }
> 
> .AppListItemField:focus + .AppListItemUpdateButton {
>   outline: solid 1px var(--create-field-outline-focus);
120a250,251
>   display: flex;
>   align-items: center;
122,123c253
<   background: transparent;
<   padding: 0 16px;
---
>   color: var(--red);
127a258,261
> }
> 
> #MenuItems button.button-destructive {
>   color: var(--button-destructive-color);
diff -r checklist/main.js checklistP2P/main.js
124,142d123
< 
<   OLSKControllerRoutes () {
<     return [{
<       OLSKRoutePath: '/#name=MainDevice&addr=MainDevice@local.host',
<       OLSKRouteMethod: 'get',
<       OLSKRouteSignature: 'AppMainRoute',
<       OLSKRouteFunction (req, res, next) {
<         return res.render(require('path').join(__dirname, 'index.html'));
<       },
<     }, {
<       OLSKRoutePath: '/#name=PeerDevice&addr=PeerDevice@local.host',
<       OLSKRouteMethod: 'get',
<       OLSKRouteSignature: 'AppPeerRoute',
<       OLSKRouteFunction (req, res, next) {
<         return res.render(require('path').join(__dirname, 'index.html'));
<       },
<     }];
<   },
< 
154c135,136
<       return
---
>       // Submit a form that's already open if another item is clicked.
>       document.getElementsByClassName('AppUpdate')[0].requestSubmit();
163,165c145,149
<     const deleteIcon = fromHTML(
<       `<svg viewBox="0 -4 32 32" width="20" height="20" fill="white" stroke="white" stroke-width="1">
<         <path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 4V6H15V4H9Z"></path></svg>`
---
>     const checkbox = document.querySelector('#' + item.guid + ' .AppListItemToggle');
>     checkbox.remove();
> 
>     const saveIcon = fromHTML(
>       `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 -2 24 24" width="16" height="16" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>`
171,173c155,156
<       h('input', {class: 'AppListItemUpdateButton AppListItemButton', type: 'submit', value: 'OK'}),
<       h('button', {class: 'AppListClearButton AppListItemButton', onclick: `AppBehaviour.ControlDelete(${ index })`},
<         deleteIcon
---
>       h('button', {class: 'AppListItemUpdateButton AppListItemButton', type: 'submit'},
>         saveIcon
175a159
> 
185a170
>       window[item.guid].appendChild(checkbox);
189a175,176
>       } else {
>         mod.ControlRefresh(index, newText);
193a181,183
> 
>     // Focus on the field after it's attached
>     form.querySelector('.AppListItemUpdateField').focus();
198a189,194
>     // If a task is being edited make sure to save it.
>     const editAlreadyOpen = document.getElementsByClassName('AppUpdate').length > 0
>     if (editAlreadyOpen) {
>       document.getElementsByClassName('AppUpdate')[0].requestSubmit();
>     }
> 
223,235d218
<       const clone = {
<         guid: item.guid,
<         text: item.text,
<         done: item.done,
<         created: item.created,
<         creator: item.creator,
<       }
<       doc.items.splice(index, 1);
<       if (item.done) {
<         doc.items.push(clone);
<       } else {
<         doc.items.unshift(clone);
<       }
244a228,231
>   ControlRefresh (index, text) {
>     mod._StoreRefresh(Automerge.change(mod._ValueDocument, function (doc) {}));
>   },
> 
252,253c239,240
<     document.getElementById('MenuItems').classList.remove("hidden")
<     document.getElementById('MenuBg').classList.remove("hidden")
---
>     document.getElementById('MenuItems').classList.add("showing");
>     document.getElementById('MenuBg').classList.remove("hidden");
258,259c245,246
<     document.getElementById('MenuBg').classList.add("hidden")
<     document.getElementById('MenuItems').classList.add("hidden")
---
>     document.getElementById('MenuBg').classList.add("hidden");
>     document.getElementById('MenuItems').classList.remove("showing");
266c253,270
<     AppSaveTitleButton.style.display = ''
---
> 
>     // If a task is being edited make sure to save it.
>     const editAlreadyOpen = document.getElementsByClassName('AppUpdate').length > 0
>     if (editAlreadyOpen) {
>       document.getElementsByClassName('AppUpdate')[0].requestSubmit();
>     }
> 
>     // Save when enter is pressed    
>     document.getElementById('AppHeading').addEventListener('keydown', (e) => {
>       if(e.keyCode == 13) {
>         AppBehaviour.ControlSaveTitle();
>       }
>     });
> 
>     // Save when the field is not focused   
>     document.getElementById('AppHeading').addEventListener('blur', (e) => {
>       AppBehaviour.ControlSaveTitle();
>     });
280,281d283
< 
<     document.getElementById('AppSaveTitleButton').style.display = 'none';
365a368,371
>   async _StoreRefresh (inputData) {
>     mod.ReactDocument(inputData);
>   },
> 
429a436,441
>     // Don't update the interface when it's currently being edited
>     const editAlreadyOpen = document.getElementsByClassName('AppUpdate').length > 0
>     if (editAlreadyOpen) {
>       return;
>     }
> 
472a485,501
> 
>   // Show the body when everything has been loaded
>   // to prevent flashing of unstyled content.
>   setTimeout(() => {
>     document.body.style.opacity = 1;
>   }, 200);
> 
>   document.getElementById("AppCreateField").addEventListener('focus', () => {
>     // If a task is being edited at the same time, make sure to save it.
>     const editAlreadyOpen = document.getElementsByClassName('AppUpdate').length > 0
>     if (editAlreadyOpen) {
>       document.getElementsByClassName('AppUpdate')[0].requestSubmit();
>     }
>   });
> 
>   // Make sure to focus when opening the app
>   document.getElementById("AppCreateField").focus();
1 Like