1
Fork 0
mirror of https://github.com/Steffo99/todocolors.git synced 2024-11-22 08:14:18 +00:00

Implement reconnection mechanism and other good things

This commit is contained in:
Steffo 2023-08-06 00:23:49 +02:00
parent ee80f373ca
commit b0eac94b18
Signed by: steffo
GPG key ID: 2A24051445686895
15 changed files with 223 additions and 142 deletions

View file

@ -8,9 +8,9 @@
<arguments value="--port=8081" /> <arguments value="--port=8081" />
<node-interpreter value="project" /> <node-interpreter value="project" />
<envs> <envs>
<env name="TODOBLUE_SITE_NAME" value="Tododev" /> <env name="NEXT_PUBLIC_TODOBLUE_OVERRIDE_BASE_URL" value="http://localhost:8080" />
<env name="TODORED_BASE_URL" value="ws://192.168.1.135:8080" />
<env name="TODOBLUE_SITE_DESCRIPTION" value="Development version of Todoblue" /> <env name="TODOBLUE_SITE_DESCRIPTION" value="Development version of Todoblue" />
<env name="TODOBLUE_SITE_NAME" value="Tododeve" />
</envs> </envs>
<method v="2" /> <method v="2" />
</configuration> </configuration>

View file

@ -10,7 +10,7 @@ export function BoardColumn({taskGroup}: {taskGroup: TaskGroup}) {
{taskGroup.name} {taskGroup.name}
</h3> </h3>
<div> <div>
{taskGroup.tasks.map(task => <TaskDisplay task={task}/>)} {taskGroup.tasks.map(task => <TaskDisplay task={task} key={task.id}/>)}
</div> </div>
</div> </div>
) )

View file

@ -36,7 +36,11 @@ function TitleArea({children}: {children: ReactNode}) {
} }
function TitleInput() { function TitleInput() {
const {editTitle, setEditTitle, stopEditingTitle} = useManagedBoard() const {editTitle, setEditTitle, stopEditingTitle, webSocketState} = useManagedBoard()
if(webSocketState !== WebSocket.OPEN) {
return null
}
return ( return (
<form onSubmit={stopEditingTitle}> <form onSubmit={stopEditingTitle}>
@ -52,7 +56,11 @@ function TitleInput() {
} }
function TitleDisplay() { function TitleDisplay() {
const {title} = useManagedBoard() const {title, webSocketState} = useManagedBoard()
if(webSocketState !== WebSocket.OPEN) {
return null
}
return ( return (
<div <div
@ -85,9 +93,9 @@ function HomeButton() {
} }
function EditTitleButton() { function EditTitleButton() {
const {websocketState, toggleEditingTitle} = useManagedBoard() const {webSocketState, toggleEditingTitle} = useManagedBoard()
if(websocketState != WebSocket.OPEN) { if(webSocketState != WebSocket.OPEN) {
return null; return null;
} }
@ -107,9 +115,9 @@ function RightButtonsArea({children}: {children: ReactNode}) {
} }
function CycleGroupButton() { function CycleGroupButton() {
const {websocketState, nextGrouper} = useManagedBoard() const {webSocketState, nextGrouper} = useManagedBoard()
if(websocketState != WebSocket.OPEN) { if(webSocketState != WebSocket.OPEN) {
return null; return null;
} }
@ -121,9 +129,9 @@ function CycleGroupButton() {
} }
function CycleSortButton() { function CycleSortButton() {
const {websocketState, nextSorter} = useManagedBoard() const {webSocketState, nextSorter} = useManagedBoard()
if(websocketState != WebSocket.OPEN) { if(webSocketState != WebSocket.OPEN) {
return null; return null;
} }

View file

@ -1,23 +1,23 @@
import {BoardMainIcon} from "@/app/board/[board]/BoardMainIcon" import {BoardMainIcon} from "@/app/board/[board]/BoardMainIcon"
import {BoardMainTaskGroups} from "@/app/board/[board]/BoardMainTaskGroups" import {BoardMainTaskGroups} from "@/app/board/[board]/BoardMainTaskGroups"
import {useManagedBoard} from "@/app/board/[board]/BoardManager" import {useManagedBoard} from "@/app/board/[board]/BoardManager"
import {faArrowsSpin, faExclamationCircle, faGear} from "@fortawesome/free-solid-svg-icons" import {faLink, faLinkSlash, faGear} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome" import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
export function BoardMain({className}: {className?: string}) { export function BoardMain({className}: {className?: string}) {
const {websocketState} = useManagedBoard() const {webSocketState} = useManagedBoard()
switch(websocketState) { switch(webSocketState) {
case undefined: case undefined:
return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faGear} spin/>} text={"Caricamento..."} className={className}/> return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faGear} beatFade/>} text={"Caricamento..."} className={className}/>
case WebSocket.CONNECTING: case WebSocket.CONNECTING:
return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faArrowsSpin} spin/>} text={"Connessione..."} className={className}/> return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faLink} beatFade/>} text={"Connessione..."} className={className}/>
case WebSocket.OPEN: case WebSocket.OPEN:
return <BoardMainTaskGroups className={className}/> return <BoardMainTaskGroups className={className}/>
case WebSocket.CLOSING: case WebSocket.CLOSING:
return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faLinkSlash} beatFade/>} text={"Disconnessione..."} className={className}/>
case WebSocket.CLOSED: case WebSocket.CLOSED:
return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faExclamationCircle}/>} text={"Errore"} className={cn("red", className)}/> return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faLinkSlash}/>} text={"Disconnesso"} className={className}/>
} }
} }

View file

@ -9,7 +9,7 @@ export function BoardMainTaskGroups({className}: {className?: string}) {
return ( return (
<main className={cn(style.boardMainTaskGroups, className)}> <main className={cn(style.boardMainTaskGroups, className)}>
{taskGroups.map((tg) => <BoardColumn taskGroup={tg}/>)} {taskGroups.map((tg) => <BoardColumn taskGroup={tg} key={tg.key}/>)}
</main> </main>
) )
} }

View file

@ -7,7 +7,7 @@ import {faAdd} from "@fortawesome/free-solid-svg-icons"
export function BoardTaskEditor({className}: {className?: string}) { export function BoardTaskEditor({className}: {className?: string}) {
const {editedTask, send, setEditedTaskText, websocketState} = useManagedBoard() const {editedTask, send, setEditedTaskText, webSocketState} = useManagedBoard()
const submitTask = useCallback((e: FormEvent) => { const submitTask = useCallback((e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -15,7 +15,7 @@ export function BoardTaskEditor({className}: {className?: string}) {
setEditedTaskText("") setEditedTaskText("")
}, [send, editedTask]) }, [send, editedTask])
if(websocketState != WebSocket.OPEN) { if(webSocketState != WebSocket.OPEN) {
return null return null
} }

View file

@ -55,25 +55,28 @@ export function TaskDisplay({task}: {task: TaskWithId}) {
} }
return ( return (
<div className={cn({ <div
"panel": true, className={cn({
[style.taskDiv]: true, "panel": true,
[style.taskDivFront]: !isDisplayingActions, [style.taskDiv]: true,
[style.taskDivBack]: isDisplayingActions, [style.taskDivFront]: !isDisplayingActions,
[style.taskPriorityHighest]: task.priority === "Highest", [style.taskDivBack]: isDisplayingActions,
[style.taskPriorityHigh]: task.priority === "High", [style.taskPriorityHighest]: task.priority === "Highest",
[style.taskPriorityNormal]: task.priority === "Normal", [style.taskPriorityHigh]: task.priority === "High",
[style.taskPriorityLow]: task.priority === "Low", [style.taskPriorityNormal]: task.priority === "Normal",
[style.taskPriorityLowest]: task.priority === "Lowest", [style.taskPriorityLow]: task.priority === "Low",
[style.taskImportanceHighest]: task.importance === "Highest", [style.taskPriorityLowest]: task.priority === "Lowest",
[style.taskImportanceHigh]: task.importance === "High", [style.taskImportanceHighest]: task.importance === "Highest",
[style.taskImportanceNormal]: task.importance === "Normal", [style.taskImportanceHigh]: task.importance === "High",
[style.taskImportanceLow]: task.importance === "Low", [style.taskImportanceNormal]: task.importance === "Normal",
[style.taskImportanceLowest]: task.importance === "Lowest", [style.taskImportanceLow]: task.importance === "Low",
[style.taskStatusUnfinished]: task.status === "Unfinished", [style.taskImportanceLowest]: task.importance === "Lowest",
[style.taskStatusInProgress]: task.status === "InProgress", [style.taskStatusUnfinished]: task.status === "Unfinished",
[style.taskStatusComplete]: task.status === "Complete", [style.taskStatusInProgress]: task.status === "InProgress",
})} onClick={() => setDisplayingActions(!isDisplayingActions)}> [style.taskStatusComplete]: task.status === "Complete",
})}
onClick={() => setDisplayingActions(!isDisplayingActions)}
>
{contents} {contents}
</div> </div>
) )

View file

@ -4,7 +4,7 @@ import {TASK_GROUPERS} from "@/app/board/[board]/doTaskGrouping"
import {TASK_SORTERS} from "@/app/board/[board]/doTaskSorting" import {TASK_SORTERS} from "@/app/board/[board]/doTaskSorting"
import {BoardAction, Task} from "@/app/board/[board]/Types" import {BoardAction, Task} from "@/app/board/[board]/Types"
import {useBoardTaskEditor} from "@/app/board/[board]/useBoardTaskEditor" import {useBoardTaskEditor} from "@/app/board/[board]/useBoardTaskEditor"
import {useBoardWebSocket} from "@/app/board/[board]/useBoardWebSocket" import {useBoardWs} from "@/app/board/[board]/useBoardWs"
import {TaskGroup, useBoardTaskArranger} from "@/app/board/[board]/useBoardTaskArranger" import {TaskGroup, useBoardTaskArranger} from "@/app/board/[board]/useBoardTaskArranger"
import {useBoardTitleEditor} from "@/app/board/[board]/useBoardTitleEditor" import {useBoardTitleEditor} from "@/app/board/[board]/useBoardTitleEditor"
import {useCycleState} from "@/app/useCycleState" import {useCycleState} from "@/app/useCycleState"
@ -14,7 +14,7 @@ export interface UseBoardReturns {
title: string, title: string,
tasksById: {[id: string]: Task}, tasksById: {[id: string]: Task},
taskGroups: TaskGroup[], taskGroups: TaskGroup[],
websocketState: number, webSocketState: number | undefined,
isEditingTitle: boolean, isEditingTitle: boolean,
stopEditingTitle: () => void, stopEditingTitle: () => void,
startEditingTitle: () => void, startEditingTitle: () => void,
@ -35,13 +35,13 @@ export interface UseBoardReturns {
} }
export function useBoard(name: string): UseBoardReturns { export function useBoard(name: string): UseBoardReturns {
const {state: {title, tasksById}, send, websocketState} = useBoardWebSocket(name); const {state: {title, tasksById}, sendAction, webSocketState} = useBoardWs(name);
const {value: [taskGrouper, groupSorter, groupNamer], move: moveGrouper, next: nextGrouper, previous: previousGrouper} = useCycleState(TASK_GROUPERS); const {value: [taskGrouper, groupSorter, groupNamer], move: moveGrouper, next: nextGrouper, previous: previousGrouper} = useCycleState(TASK_GROUPERS);
const {value: taskSorter, move: moveSorter, next: nextSorter, previous: previousSorter} = useCycleState(TASK_SORTERS); const {value: taskSorter, move: moveSorter, next: nextSorter, previous: previousSorter} = useCycleState(TASK_SORTERS);
const {taskGroups} = useBoardTaskArranger(tasksById, taskGrouper, groupSorter, groupNamer, taskSorter); const {taskGroups} = useBoardTaskArranger(tasksById, taskGrouper, groupSorter, groupNamer, taskSorter);
const {isEditingTitle, stopEditingTitle, startEditingTitle, toggleEditingTitle, editTitle, setEditTitle} = useBoardTitleEditor(title, send); const {isEditingTitle, stopEditingTitle, startEditingTitle, toggleEditingTitle, editTitle, setEditTitle} = useBoardTitleEditor(title, sendAction);
const {editedTaskText, setEditedTaskText, editedTask, setEditedTask} = useBoardTaskEditor() const {editedTaskText, setEditedTaskText, editedTask, setEditedTask} = useBoardTaskEditor()
@ -49,7 +49,7 @@ export function useBoard(name: string): UseBoardReturns {
title, title,
tasksById, tasksById,
taskGroups, taskGroups,
websocketState, webSocketState,
isEditingTitle, isEditingTitle,
stopEditingTitle, stopEditingTitle,
startEditingTitle, startEditingTitle,
@ -62,7 +62,7 @@ export function useBoard(name: string): UseBoardReturns {
previousSorter, previousSorter,
editTitle, editTitle,
setEditTitle, setEditTitle,
send, send: sendAction,
editedTaskText, editedTaskText,
setEditedTaskText, setEditedTaskText,
editedTask, editedTask,

View file

@ -1,36 +0,0 @@
'use client';
import {BoardAction} from "@/app/board/[board]/Types"
import {useBoardState} from "@/app/board/[board]/useBoardState"
import {useBoardWebSocketURL} from "@/app/board/[board]/useBoardWebSocketURL"
import {useCallback} from "react"
import {useWebSocket} from "@/app/useWebSocket"
export function useBoardWebSocket(name: string) {
const {webSocketURL} = useBoardWebSocketURL(name)
const {state, act} = useBoardState();
const {websocket, websocketState} = useWebSocket(webSocketURL, {
onopen: useCallback((_sock: WebSocket, _event: Event) => {
console.debug("[useBoard] Connected to board:", name);
act(null);
}, []),
onmessage: useCallback((_sock: WebSocket, event: MessageEvent) => {
const action: BoardAction = JSON.parse(event.data);
console.debug("[useBoard] Received:", action);
act(action)
}, []),
});
const send = useCallback((data: BoardAction) => {
if(!websocket || websocketState !== WebSocket.OPEN) {
console.warn("[useBoardWebSocket] Webbsocket is not yet ready, cannot send:", data);
return;
}
console.debug("[useBoardWebSocket] Sending:", data);
websocket.send(JSON.stringify(data));
}, [websocket, websocketState])
return {state, send, websocketState}
}

View file

@ -1,17 +0,0 @@
"use client";
import {useMemo} from "react"
const HTTP_TO_WS = {
"http:": "ws:",
"https:": "wss:",
}
export function useBoardWebSocketURL(name: string) {
// @ts-ignore
const protocol = HTTP_TO_WS[window.location.protocol]
const host = window.location.host
const webSocketURL = useMemo(() => `${protocol}//${host}/api/board/${name}/ws`, [name]);
return {webSocketURL}
}

View file

@ -0,0 +1,46 @@
'use client';
import {BoardAction} from "@/app/board/[board]/Types"
import {useBoardState} from "@/app/board/[board]/useBoardState"
import {useWsBaseURL} from "@/app/useWsBaseURL"
import {useCallback, useMemo} from "react"
import {useWs, WebSocketHandlerParams} from "@/app/useWs"
export function useBoardWs(name: string) {
const wsBaseURL = useWsBaseURL()
const wsFullURL = useMemo(() => wsBaseURL ? `${wsBaseURL}/board/${name}/ws` : undefined, [wsBaseURL, name])
const {state, act} = useBoardState();
const {webSocket, webSocketState} = useWs(wsFullURL, {
onopen: useCallback(({}) => {
console.debug("[useBoardWs] Connected to board:", name);
act(null);
}, [act]),
onmessage: useCallback(({event}: WebSocketHandlerParams<MessageEvent>) => {
const action: BoardAction = JSON.parse(event.data);
console.debug("[useBoardWs] Parsed payload as:", action);
act(action)
}, [act]),
onerror: useCallback(({event, closeWebSocket}: WebSocketHandlerParams<Event>) => {
console.error("[useBoardWs] Encountered a WebSocket error, closing current connection:", event);
closeWebSocket()
}, []),
onclose: useCallback(({event, openWebSocket}: WebSocketHandlerParams<Event>) => {
console.debug("[useBoardWs] WebSocket was closed, trying to reconnect:", event);
openWebSocket()
}, [])
});
const sendAction = useCallback((data: BoardAction) => {
if(!webSocket || webSocketState !== WebSocket.OPEN) {
console.warn("[useBoardWs] WebSocket is not yet ready, cannot send:", data);
return;
}
console.debug("[useBoardWs] Sending:", data);
webSocket.send(JSON.stringify(data));
}, [webSocket, webSocketState])
return {state, sendAction, webSocketState}
}

View file

@ -0,0 +1,20 @@
import {useEffect, useState} from "react"
/**
* **Hook** returning the base URL to use in API calls.
*
* Obtains the base URL from `window.location`, unless the `NEXT_PUBLIC_TODOBLUE_OVERRIDE_BASE_URL` environment variable is set, which takes precedence in that case.
*/
export function useHttpBaseURL(): string | undefined {
const [baseURL, setBaseURL] = useState<string | undefined>(undefined);
useEffect(() => {
let url = process.env.NEXT_PUBLIC_TODOBLUE_OVERRIDE_BASE_URL;
if(!url) url = `${window.location.protocol}//${window.location.host}`;
console.debug("[useBaseURL] Using base URL:", url);
setBaseURL(url);
}, []);
return baseURL;
}

View file

@ -1,43 +0,0 @@
'use client';
import {useState, useEffect, useCallback} from "react"
export interface WebSocketHandlers {
onclose?: (sock: WebSocket, event: CloseEvent) => void,
onerror?: (sock: WebSocket, event: Event) => void,
onmessage?: (sock: WebSocket, event: MessageEvent) => void,
onopen?: (sock: WebSocket, event: Event) => void,
}
export function useWebSocket(url: string, {onclose, onerror, onmessage, onopen}: WebSocketHandlers) {
const [websocket, setWebsocket] = useState<WebSocket | null>(null)
const [websocketState, setWebsocketState] = useState<number>(0);
useEffect(() => {
console.debug("[useWebSocket] Creating websocket...");
const sock = new WebSocket(url);
setWebsocket(sock);
sock.onopen = (ev) => {
setWebsocketState(sock.readyState);
onopen?.(sock, ev);
}
sock.onclose = (ev) => {
setWebsocketState(sock.readyState);
onclose?.(sock, ev);
}
sock.onerror = (ev) => {
setWebsocketState(sock.readyState);
onerror?.(sock, ev);
}
sock.onmessage = (ev) => {
onmessage?.(sock, ev);
}
return () => {
console.debug("[useWebSocket] Closing websocket...");
sock.close();
setWebsocket(null);
}
}, [url, onclose, onerror, onmessage, onopen])
return {websocket, websocketState}
}

91
todoblue/src/app/useWs.ts Normal file
View file

@ -0,0 +1,91 @@
'use client';
import {useState, useCallback, useEffect} from "react"
export interface WebSocketHandlerParams<E extends Event> {
event: E,
openWebSocket: () => void,
closeWebSocket: () => void,
}
export interface WebSocketHandlers {
onclose?: (params: WebSocketHandlerParams<CloseEvent>) => void,
onerror?: (params: WebSocketHandlerParams<Event>) => void,
onmessage?: (params: WebSocketHandlerParams<MessageEvent>) => void,
onopen?: (params: WebSocketHandlerParams<Event>) => void,
}
export function useWs(url: string | undefined, {onclose, onerror, onmessage, onopen}: WebSocketHandlers) {
const [webSocket, setWebSocket] = useState<WebSocket | undefined>(undefined)
const [webSocketState, setWebSocketState] = useState<number | undefined>(undefined);
const [webSocketBackoffMs, setWebSocketBackoffMs] = useState<number>(1);
const closeWebSocket = useCallback(() => {
console.debug("[useWebSocket] Closing WebSocket:", webSocket);
if(webSocket === undefined) {
console.warn("[useWebSocket] Trying to close WebSocket, but no WebSocket is open; ignoring request...")
return;
}
try {
webSocket.close();
}
catch(closeErr) {
console.debug("[useWebSocket] Failed to close the websocket (it might be already closed):", closeErr)
}
setWebSocket(undefined);
setWebSocketState(WebSocket.CLOSED);
}, [webSocket])
const openWebSocket = useCallback(() => {
console.debug("[useWebSocket] Opening WebSocket:", url);
if(url === undefined) {
console.warn("[useWebSocket] Trying to open WebSocket, but no URL has been given; ignoring request...")
return;
}
setWebSocketState(WebSocket.CONNECTING); // Workaround for constructor blocking and giving no feedback to the user
const sock = new WebSocket(url);
sock.onopen = (event) => {
console.debug("[useWebSocket] Opened connection:", event)
setWebSocket(sock)
setWebSocketState(sock.readyState)
setWebSocketBackoffMs(1)
onopen?.({event, openWebSocket, closeWebSocket});
}
sock.onclose = (event) => {
console.debug("[useWebSocket] Closed connection:", event)
setWebSocket(undefined)
setWebSocketState(sock.readyState);
onclose?.({event, openWebSocket, closeWebSocket});
}
sock.onerror = (event) => {
console.error("[useWebSocket] Error in connection:", event)
setWebSocketState(sock.readyState)
setWebSocketBackoffMs(prev => prev * 2)
onerror?.({event, openWebSocket, closeWebSocket});
}
sock.onmessage = (event) => {
console.debug("[useWebSocket] Received message:", event)
onmessage?.({event, openWebSocket, closeWebSocket});
}
}, [url, onopen, onclose, onerror, onmessage])
// @ts-ignore
const openWebSocketAfterBackoff = useCallback(() => {
console.debug("[useWebSocket] Backing off for:", webSocketBackoffMs, "ms")
return setTimeout(openWebSocket, webSocketBackoffMs)
}, [openWebSocket, webSocketBackoffMs])
useEffect(() => {
if(!url) {
return;
}
console.debug("[useWebSocket] Hook mounted, opening connection as soon as possible...")
// @ts-ignore
const handle = openWebSocketAfterBackoff()
return () => {
clearTimeout(handle)
}
}, [url, openWebSocketAfterBackoff])
return {webSocket, webSocketState, openWebSocket, closeWebSocket}
}

View file

@ -0,0 +1,9 @@
import {useHttpBaseURL} from "@/app/useHttpBaseURL"
/**
* **Hook** similar to {@link useHttpBaseURL}, but returning the websocket URL instead.
*/
export function useWsBaseURL() {
return useHttpBaseURL()?.replace(/^http/, "ws")
}