Skip to content

Commit 75e1cfc

Browse files
committed
[broadcast] Drafts scripting Gephi Lite
Details: - Drafts a new broadcasting feature, that allows scripting Gephi Lite from another browser window / tab - Drafts two actions to feed Gephi Lite and to update appearance state
1 parent a3e1570 commit 75e1cfc

File tree

7 files changed

+172
-7
lines changed

7 files changed

+172
-7
lines changed

src/core/Initialize.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FC, PropsWithChildren, useCallback, useEffect } from "react";
1+
import { FC, PropsWithChildren, useCallback, useEffect, useState } from "react";
22
import { useTranslation } from "react-i18next";
33
import useKonami from "react-use-konami";
44

@@ -7,6 +7,7 @@ import { extractFilename } from "../utils/url";
77
import { WelcomeModal } from "../views/graphPage/modals/WelcomeModal";
88
import { appearanceAtom } from "./appearance";
99
import { parseAppearanceState } from "./appearance/utils";
10+
import { useBroadcast } from "./broadcast/useBroadcast";
1011
import { useGraphDatasetActions, useImportActions } from "./context/dataContexts";
1112
import { filtersAtom } from "./filters";
1213
import { parseFiltersState } from "./filters/utils";
@@ -32,6 +33,8 @@ export const Initialize: FC<PropsWithChildren<unknown>> = ({ children }) => {
3233
const { openModal } = useModal();
3334
const { importFile } = useImportActions();
3435
const { resetGraph } = useGraphDatasetActions();
36+
const [broadcastID, setBroadcastID] = useState<string | null>(null);
37+
useBroadcast(broadcastID);
3538

3639
useKonami(
3740
() => {
@@ -82,17 +85,20 @@ export const Initialize: FC<PropsWithChildren<unknown>> = ({ children }) => {
8285
let graphFound = false;
8386
let showWelcomeModal = true;
8487
const url = new URL(window.location.href);
88+
const broadcastID = url.searchParams.get("broadcast");
89+
setBroadcastID(broadcastID);
8590

8691
// If query params has new
8792
// => empty graph & open welcome modal
88-
if (url.searchParams.has("new")) {
93+
if (url.searchParams.has("new") || broadcastID) {
8994
resetGraph();
9095
graphFound = true;
9196
url.searchParams.delete("new");
9297
window.history.pushState({}, "", url);
98+
showWelcomeModal = false;
9399
}
94100

95-
// If query params has file (or gexf although it's deprecated)
101+
// If query params has file (or GEXF, although it's deprecated)
96102
// => try to load the file
97103
if (!graphFound && (url.searchParams.has("file") || url.searchParams.has("gexf"))) {
98104
if (!url.searchParams.has("file") && url.searchParams.has("gexf"))
@@ -157,7 +163,15 @@ export const Initialize: FC<PropsWithChildren<unknown>> = ({ children }) => {
157163
* => run the initialize function
158164
*/
159165
useEffect(() => {
160-
initialize();
166+
initialize().catch((error) => {
167+
console.error(error);
168+
notify({
169+
type: "error",
170+
title: t("error.title"),
171+
message: t("error.message"),
172+
});
173+
});
174+
// eslint-disable-next-line react-hooks/exhaustive-deps
161175
}, [initialize]);
162176

163177
return (

src/core/broadcast/actions.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Graph from "graphology";
2+
import { SerializedGraph } from "graphology-types";
3+
4+
import { appearanceAtom } from "../appearance";
5+
import { AppearanceState } from "../appearance/types";
6+
import { resetStates } from "../context/dataContexts";
7+
import { graphDatasetActions } from "../graph";
8+
import { importStateAtom } from "../graph/import";
9+
import { initializeGraphDataset } from "../graph/utils";
10+
import { resetCamera } from "../sigma";
11+
import { Producer, asyncAction, producerToAction } from "../utils/producers";
12+
13+
/**
14+
* Actions:
15+
* ********
16+
*/
17+
const importGraph = asyncAction(async (data: SerializedGraph, title?: string) => {
18+
if (importStateAtom.get().type === "loading") throw new Error("A file is already being loaded");
19+
importStateAtom.set({ type: "loading" });
20+
try {
21+
const graph = Graph.from(data);
22+
if (title) graph.setAttribute("title", title);
23+
24+
const { setGraphDataset } = graphDatasetActions;
25+
resetStates(false);
26+
setGraphDataset({ ...initializeGraphDataset(graph) });
27+
resetCamera({ forceRefresh: true });
28+
} catch (e) {
29+
importStateAtom.set({ type: "error", message: (e as Error).message });
30+
throw e;
31+
} finally {
32+
importStateAtom.set({ type: "idle" });
33+
}
34+
});
35+
36+
const updateAppearance: Producer<AppearanceState, [Partial<AppearanceState>]> = (newState) => {
37+
return (state) => ({ ...state, ...newState });
38+
};
39+
40+
export const broadcastActions = {
41+
importGraph,
42+
updateAppearance: producerToAction(updateAppearance, appearanceAtom),
43+
};

src/core/broadcast/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { SerializedGraph } from "graphology-types";
2+
3+
import { AppearanceState } from "../appearance/types";
4+
5+
interface BaseBroadcastMessage {
6+
action: string;
7+
payload: unknown;
8+
}
9+
10+
export interface ImportGraphBroadcastMessage extends BaseBroadcastMessage {
11+
action: "importGraph";
12+
payload: {
13+
title?: string;
14+
graph: SerializedGraph;
15+
};
16+
}
17+
18+
export interface UpdateAppearanceBroadcastMessage extends BaseBroadcastMessage {
19+
action: "updateAppearance";
20+
payload: Partial<AppearanceState>;
21+
}
22+
23+
export type BroadcastMessage = ImportGraphBroadcastMessage | UpdateAppearanceBroadcastMessage;

src/core/broadcast/useBroadcast.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useEffect } from "react";
2+
import { useTranslation } from "react-i18next";
3+
import { TypeGuardError, assert } from "typia";
4+
5+
import { useNotifications } from "../notifications";
6+
import { broadcastActions } from "./actions";
7+
import { BroadcastMessage } from "./types";
8+
9+
const MESSAGE_READY = "ready";
10+
const MESSAGE_ACTION_ENDED = "action-ended";
11+
12+
export function useBroadcast(broadcastID?: string | null) {
13+
const { t } = useTranslation();
14+
const { notify } = useNotifications();
15+
16+
useEffect(() => {
17+
if (!broadcastID) return;
18+
19+
const channel = new BroadcastChannel(broadcastID);
20+
channel.onmessage = ({ isTrusted, data }) => {
21+
if (!isTrusted) {
22+
notify({
23+
type: "warning",
24+
title: t("broadcast.title", { id: broadcastID }),
25+
message: t("broadcast.untrusted_message"),
26+
});
27+
}
28+
29+
// Validate data type:
30+
try {
31+
const { action, payload } = assert<BroadcastMessage>(data);
32+
switch (action) {
33+
case "importGraph":
34+
broadcastActions
35+
.importGraph(payload.graph, payload.title)
36+
.then(() => {
37+
channel.postMessage({ message: MESSAGE_ACTION_ENDED, action });
38+
})
39+
.catch(() => {
40+
notify({
41+
type: "error",
42+
title: t("broadcast.title", { id: broadcastID }),
43+
message: t("error.unknown"),
44+
});
45+
});
46+
break;
47+
case "updateAppearance":
48+
broadcastActions.updateAppearance(payload);
49+
channel.postMessage({ message: MESSAGE_ACTION_ENDED, action });
50+
break;
51+
default:
52+
notify({
53+
type: "warning",
54+
title: t("broadcast.title", { id: broadcastID }),
55+
message: t("broadcast.unimplemented_message", { type: action }),
56+
});
57+
}
58+
} catch (e) {
59+
if (e instanceof TypeGuardError)
60+
notify({
61+
type: "error",
62+
title: t("broadcast.title", { id: broadcastID }),
63+
message: (
64+
<>
65+
{t("broadcast.wrongly_shaped_message")}
66+
<br />
67+
{e + ""}
68+
</>
69+
),
70+
});
71+
}
72+
};
73+
channel.postMessage({ message: MESSAGE_READY });
74+
75+
return () => {
76+
channel.close();
77+
};
78+
}, [broadcastID, notify, t]);
79+
}

src/core/utils/producers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { WritableAtom } from "./atoms";
33
export type Reducer<T> = (v: T) => T;
44

55
export type Action<Args extends unknown[] = []> = (...args: Args) => void;
6-
export type AsyncAction<Args extends unknown[]> = (...args: Args) => Promise<void>;
6+
export type AsyncAction<Args extends unknown[] = []> = (...args: Args) => Promise<void>;
77

88
/**
99
* A short function to help automatically type an AsyncAction Args generic.

src/hooks/useKeyboardShortcuts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { omit } from "lodash";
2-
import { useRef, useEffect, useCallback } from "react";
2+
import { useCallback, useEffect, useRef } from "react";
33

44
type OptionalKeys = {
55
shiftKey?: boolean;

src/locales/dev.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -764,5 +764,11 @@
764764
}
765765
},
766766
"theme": "Choose a theme"
767+
},
768+
"broadcast": {
769+
"title": "Broadcast {{id}}",
770+
"untrusted_message": "Latest message was untrusted. You might want to check the BroadcastChannel setup.",
771+
"unimplemented_message": "Messages of type \"{{type}}\" are not implemented yet.",
772+
"type_error": "Latest message was not properly typed:"
767773
}
768-
}
774+
}

0 commit comments

Comments
 (0)