feat: steadier live updates and notices for public proposals
- The web pings the live connection, reconnects when it goes quiet and reloads what each screen shows after reconnecting. - Dropping a card on a column it cannot go to puts it back. - Owners and admins get a notice when a proposal arrives. - The proposal limit lives in Redis, so it holds across instances. - REST tells whether I voted; pasted images leave the attachment list. - Deleting a project removes its Git integration; no lint warnings left. - Reactions and the blocked badge are components of their own. - Docs: what is done and what is left (chapter 10), Word and PDF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,3 +2,4 @@ export * from './lib/api-config';
|
||||
export * from './lib/access-token-source';
|
||||
export * from './lib/graphql.provider';
|
||||
export * from './lib/graphql-errors';
|
||||
export * from './lib/realtime-connection';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { HttpLink } from 'apollo-angular/http';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import { AccessTokenSource } from './access-token-source';
|
||||
import { API_CONFIG } from './api-config';
|
||||
import { RealtimeConnection } from './realtime-connection';
|
||||
|
||||
/**
|
||||
* Apollo client for the task-stream API: HTTP for queries/mutations,
|
||||
@@ -22,6 +23,10 @@ import { API_CONFIG } from './api-config';
|
||||
* The cache is disabled on purpose: state lives in the NgRx signal stores,
|
||||
* which are fed by queries and kept fresh by subscriptions.
|
||||
*/
|
||||
/** How often the socket is pinged, and how long a pong may take. */
|
||||
const PING_EVERY_MS = 10_000;
|
||||
const PONG_WITHIN_MS = 5_000;
|
||||
|
||||
export function provideGraphql(): EnvironmentProviders {
|
||||
return makeEnvironmentProviders([
|
||||
provideApollo(() => {
|
||||
@@ -50,16 +55,43 @@ export function provideGraphql(): EnvironmentProviders {
|
||||
}
|
||||
});
|
||||
|
||||
const connection = inject(RealtimeConnection);
|
||||
// A socket that goes quiet is cut by proxies (Traefik's idle timeout),
|
||||
// and one that dies without a close frame is never noticed: subscribers
|
||||
// just stop hearing. Pings keep it busy; a ping with no pong in time
|
||||
// closes it, and the client reconnects (with a fresh token).
|
||||
let socket: WebSocket | null = null;
|
||||
let pongTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let connectedBefore = false;
|
||||
const ws = new GraphQLWsLink(
|
||||
createClient({
|
||||
url: config.graphqlWsUrl,
|
||||
lazy: true,
|
||||
retryAttempts: Infinity,
|
||||
shouldRetry: () => true,
|
||||
keepAlive: PING_EVERY_MS,
|
||||
connectionParams: async () => {
|
||||
const token = await tokens.accessToken();
|
||||
return token ? { authorization: `Bearer ${token}` } : {};
|
||||
},
|
||||
on: {
|
||||
connected: (opened) => {
|
||||
socket = opened as WebSocket;
|
||||
// What happened while it was down was lost: views reload.
|
||||
if (connectedBefore) connection.reconnected();
|
||||
connectedBefore = true;
|
||||
},
|
||||
ping: (received) => {
|
||||
if (received) return;
|
||||
pongTimeout = setTimeout(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN)
|
||||
socket.close(4408, 'No pong');
|
||||
}, PONG_WITHIN_MS);
|
||||
},
|
||||
pong: (received) => {
|
||||
if (received) clearTimeout(pongTimeout);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* The state of the subscriptions' socket that the app cares about: when it
|
||||
* comes back after a cut. Whatever happened meanwhile was not received, so
|
||||
* views reload when it does.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RealtimeConnection {
|
||||
private readonly back = new Subject<void>();
|
||||
|
||||
/** Each time the socket reconnects (not the first time it connects). */
|
||||
readonly reconnected$: Observable<void> = this.back.asObservable();
|
||||
|
||||
/** Called by the GraphQL client. */
|
||||
reconnected(): void {
|
||||
this.back.next();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user