Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions doc/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ This is how user actions are handled:
* ~~When the text is changed in the compose window, `compose` sends the update to `background`, which relays it to the server, which updates the text editor.~~ (Not until v2.0.0)
1. The WebSocket connection remains open until one of the following happens:
* a) When the server closes the WebSocket connection, which `background` detects and notifies `compose`.
* b) When the compose window is closed, `compose` closes the `Port`, which `background` detects and closes the WebSocket connection.
* b) When the shortcut key assigned to the close function is pressed, `compose` closes the `Port`, which `background` detects and closes the WebSocket connection.
* c) When the compose window is closed, `compose` closes the `Port`, handled as the same as (b).
1. The compose window returns to its normal state and the button is toggled off.

### The options page
Expand Down Expand Up @@ -81,11 +82,8 @@ loop
B->>C: Close the port
end
else
break When the Ghostbird button has been clicked again
B->>B: User clicks the Ghostbird button
B->>C: Request a graceful close
C->>B: Send Last update
B->>S: Send Last update
break When the close shortcut key is pressed
B->>B: User types the shortcut key
B->>C: Close the port
B->>S: Close the WebSocket
end
Expand Down
2 changes: 1 addition & 1 deletion src/app-background/background_event_router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class BackgroundEventRouter {
return Promise.reject(Error("Event dropped"))
}

return this.composeActionNotifier.toggle(composeTab)
return this.composeActionNotifier.start(composeTab)
}

/** handles one-off messages from content scripts */
Expand Down
10 changes: 5 additions & 5 deletions src/app-background/compose_action_notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class ComposeActionNotifier {

async start(tab: IComposeWindow): Promise<void> {
if (this.findOpenPort(tab)) {
console.log("Port is open; skipping")
console.info("Port is already open; skipping")
return
}
await tab.prepareContentScript()
Expand All @@ -19,11 +19,11 @@ export class ComposeActionNotifier {
this.runners.set(tab.tabId, port)

try {
console.log("starting session")
console.info("starting session")
let editor = new EmailEditor(tab, port)
await this.ghostTextRunner.run(editor, editor)
} finally {
console.log("session closed")
console.info("session closed")
this.close(tab, port)
}
}
Expand All @@ -35,10 +35,10 @@ export class ComposeActionNotifier {
async toggle(tab: IComposeWindow): Promise<void> {
let port = this.findOpenPort(tab)
if (port) {
console.log("toggle: closing")
console.info("toggle: closing")
this.close(tab, port)
} else {
console.log("toggle: starting")
console.info("toggle: starting")
await this.start(tab)
}
}
Expand Down
6 changes: 2 additions & 4 deletions src/app-compose/api.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { IMessagePort, IMessenger } from "../ghosttext-runner/message"
import type { EditorChangeResponse, IEditorState } from "../ghosttext-session"
import type { IMessenger } from "../ghosttext-runner/message"

/** Connection to the background script, and the GhostText server behind it */
export type IGhostClientPort = IMessagePort<Partial<IEditorState>, EditorChangeResponse>
export type { IGhostClientPort } from "../ghosttext-adaptor/api"

/** Can send one-off messages to background */
export type IBackgroundMessenger = IMessenger<{ ping: "pong" }>
61 changes: 38 additions & 23 deletions src/app-compose/compose_event_router.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,36 @@
import type { MessagesFromBackground } from "../ghosttext-runner/message"
import type { ExternalEdit, MessagesFromBackground } from "../ghosttext-runner"
import type { BodyState } from "../ghosttext-session/types"
import type { IBackgroundMessenger, IGhostClientPort } from "./api"

export class ComposeEventRouter {
static isSingleton = true

constructor(readonly backgroundMessenger: IBackgroundMessenger) {}
constructor(
readonly body: HTMLBodyElement,
readonly backgroundMessenger: IBackgroundMessenger,
) {}

async handleConnect(port: IGhostClientPort): Promise<void> {
const body = document.body
const clearReadOnly = makeReadOnly(body)
const clearReadOnly = makeReadOnly(this.body)
try {
console.info({ connected: Date.now(), date: new Date() })

port.send({ html: this.body.innerHTML, plainText: this.body.textContent } satisfies BodyState)

for (;;) {
let ready = await port.waitReady().then(
() => true,
() => false,
)
if (!ready) {
break
}
await port.waitReady()

let got = port.clearReceived()
console.debug({ got })
if (!got?.text) {

if (!got || !this.applyEdit(got)) {
break
}

// TODO sync cursors
body.textContent = got.text
// TODO send changes
// TODO reconnect
}
} catch (thrown) {
console.info({ thrown })
} finally {
console.info({ disconnected: Date.now(), date: new Date() })
clearReadOnly()
Expand All @@ -40,16 +42,26 @@ export class ComposeEventRouter {

if (message === "ping") {
return response(message)
} else if (message === "start") {
return "ok"
} else if (message === "stop") {
return "ok"
} else if (message === "toggle") {
return "ok"
} else {
return "ok"
}
}

private applyEdit(got: ExternalEdit): boolean {
console.debug({ got })

if (typeof got.plainText === "string") {
this.body.textContent = got.plainText
return true
}

if (typeof got.html === "string") {
this.body.innerHTML = got.html
return true
}

return false
}
}

function response(_message: "ping"): MessagesFromBackground["ping"] {
Expand All @@ -67,15 +79,18 @@ function makeReadOnly(body: HTMLElement): () => void {
const controller = new AbortController()
const option = { signal: controller.signal }

body.style.background = "slategray"

body.addEventListener("beforeinput", disable, option)
body.addEventListener("paste", disable, option)
body.addEventListener("cut", disable, option)
body.addEventListener("drop", disable, option)
body.addEventListener("dragover", disable, option)

body.style.background = "lightgray"
body.style.pointerEvents = "none"
body.blur()

return () => {
body.style.removeProperty("pointerEvents")
body.style.removeProperty("background")
controller.abort()
}
Expand Down
5 changes: 1 addition & 4 deletions src/app-compose/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@
"path": "../util"
},
{
"path": "../ghosttext-session"
},
{
"path": "../ghosttext-runner"
"path": "../ghosttext-adaptor"
}
]
}
12 changes: 6 additions & 6 deletions src/ghosttext-adaptor/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { IMessagePort } from "../ghosttext-runner"
import type { EditorChangeResponse, IEditorState } from "../ghosttext-session"
import type { ExternalEdit, IMessagePort, InternalEdit } from "../ghosttext-runner"

/** Wrapper for `fetch` and `WebSocket` */
export interface IWebClient {
Expand Down Expand Up @@ -29,9 +28,7 @@ export type ComposeDetails = {
}

/** Updatable fields of a compose window */
export type SettableComposeDetails =
| { subject: string; body?: string | undefined }
| { subject?: string | undefined; body: string }
export type SettableComposeDetails = { subject: string }

/** an utility to interact with a mail compose window */
export interface IComposeWindow {
Expand Down Expand Up @@ -67,4 +64,7 @@ export interface IComposeWindow {
setIcon(imageFilePath: string): Promise<void>
}

export type IGhostServerPort = IMessagePort<EditorChangeResponse, Partial<IEditorState>>
/** Connection to the background script, and the GhostText server behind it */
export type IGhostClientPort = IMessagePort<InternalEdit, ExternalEdit>
/** Connection to the content script */
export type IGhostServerPort = IMessagePort<ExternalEdit, InternalEdit>
32 changes: 20 additions & 12 deletions src/ghosttext-adaptor/email_editor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ClientStatus, IClientEditor, IStatusIndicator } from "../ghosttext-runner"
import type { EditorChangeResponse, IEditorState } from "../ghosttext-session"
import type { ClientStatus, ExternalEdit, IClientEditor, InternalEdit, IStatusIndicator } from "../ghosttext-runner"
import type { EmailState } from "../ghosttext-session"
import type { IComposeWindow, IGhostServerPort } from "./api"

export class EmailEditor implements IClientEditor, IStatusIndicator {
Expand All @@ -12,30 +12,38 @@ export class EmailEditor implements IClientEditor, IStatusIndicator {
return this.composeWindow.setIcon(imageForStatus(status))
}

async getState(): Promise<IEditorState> {
this.port.clearReceived()
async getState(): Promise<EmailState> {
let { body, subject, isPlainText } = await this.composeWindow.getDetails()

let { body, subject } = await this.composeWindow.getDetails()
// Expect body from the compose window
await this.waitEdit()
let r = this.port.clearReceived()
if (isPlainText && r && "plainText" in r && r.plainText != null) {
body = r.plainText
} else if (!isPlainText && r && "html" in r && r.html != null) {
body = r.html
}

// TODO Add an option to strip HTML tags on edit
// TODO pass a better url that identify the email

return { selections: [{ start: 0, end: 0 }], subject, text: body, url: import.meta.url }
let { host } = new URL(import.meta.url)

return { selections: [{ start: 0, end: 0 }], subject, isPlainText, body, url: host }
}

async applyChange(change: EditorChangeResponse): Promise<void> {
async applyChange(change: ExternalEdit): Promise<void> {
// TODO Should re-add HTML tags like `<p></p>` if stripped
if (change.text) {
this.port.send(change)
}
this.port.send(change)
}

waitEdit(): Promise<void> {
return this.port.waitReady()
}

popLastEdit(): Partial<IEditorState> | undefined {
return this.port.clearReceived()
popLastEdit(): InternalEdit | undefined {
let edits = this.port.clearReceived()
return edits
}
}

Expand Down
3 changes: 0 additions & 3 deletions src/ghosttext-adaptor/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@
{
"path": "../util"
},
{
"path": "../ghosttext-session"
},
{
"path": "../ghosttext-runner"
}
Expand Down
12 changes: 8 additions & 4 deletions src/ghosttext-runner/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {
EditorChangeResponse,
IEditorState,
EmailState,
ExternalEdit,
InternalEdit,
ServerInitialResponse,
UpdateRequest,
} from "../ghosttext-session/types"
Expand Down Expand Up @@ -60,14 +62,14 @@ export interface ISession {
*/
export interface IClientEditor {
/** @returns complete current state of the editor */
getState(): Promise<IEditorState>
getState(): Promise<EmailState>

/**
* Applies changes received from the GhostText server to the compose window
* @param change The change from the server
* @returns resolves when updated, or rejects if the editor has been closed
*/
applyChange(change: EditorChangeResponse): Promise<void>
applyChange(change: ExternalEdit): Promise<void>

/**
* Waits for an edit to occur in the local compose window.
Expand All @@ -79,9 +81,11 @@ export interface IClientEditor {
* Get the most recent edit and drop the rest
* @returns recent edit or `undefined` if no new edits have occurred
*/
popLastEdit(): Partial<IEditorState> | undefined
popLastEdit(): InternalEdit | undefined
}

export type { InternalEdit, ExternalEdit }

/**
* Status of the connection
* - `inactive`: Not connected or starting
Expand Down
12 changes: 7 additions & 5 deletions src/ghosttext-runner/ghost_text_runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,14 @@ export class GhostTextRunner {
): Promise<CommandResult> {
switch (command.type) {
case "queryEditor":
return { type: "editorState", state: await editor.getState() }
return { type: "clientState", state: await editor.getState() }
case "requestUpdate":
session.sendUpdate(command.update)
return receiveChange(editor, session)
case "applyChange":
await editor.applyChange(command.change)
if (command.change) {
await editor.applyChange(command.change)
}
return receiveChange(editor, session)
case "notifyStatus":
await indicator.update(translateStatus(command.status))
Expand All @@ -116,7 +118,7 @@ async function receiveChange(editor: IClientEditor, session: ISession): Promise<
async function receiveChangeOnce(editor: IClientEditor, session: ISession): Promise<CommandResult | undefined> {
let type = await Promise.race([
editor.waitEdit().then(
() => "partialEditorState" as const,
() => "clientEdited" as const,
() => "editorClosed" as const,
),
session.waitServerChange().then(
Expand All @@ -125,9 +127,9 @@ async function receiveChangeOnce(editor: IClientEditor, session: ISession): Prom
),
])

if (type === "partialEditorState") {
if (type === "clientEdited") {
let state = editor.popLastEdit()
return state && { type, state }
return state && { type, edit: state }
} else if (type === "serverChanged") {
let change = session.popServerChange()
return change && { type, change }
Expand Down
4 changes: 1 addition & 3 deletions src/ghosttext-runner/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

export interface MessagesFromBackground {
ping: "pong"
start: "ok"
stop: "ok"
toggle: "ok"
other: "ok"
}

/**
Expand Down
Loading