vojo/src/app/plugins/polling.ts

92 lines
3.7 KiB
TypeScript

// Bridge to the native PollingPlugin (see
// android/app/src/main/java/chat/vojo/app/PollingPlugin.java).
//
// Drives the WorkManager-based /notifications polling fallback used on
// networks where FCM (mtalk.google.com:5228) is blocked. JS owns the
// credential + room-name cache lifecycle; native owns the periodic fetch
// and notification rendering.
//
// Web has no analogue: the Service Worker already wakes for push without
// needing a periodic poll, and browsers don't expose a 15-minute periodic
// background API anyway. The web fallback is a no-op.
import { registerPlugin } from '@capacitor/core';
import { isAndroidPlatform } from '../utils/capacitor';
export type RoomMetadataMap = Record<
string,
| string
| {
name: string;
isDirect: boolean;
isEncrypted: boolean;
// MXC URL for the room's avatar (or, for a DM with no explicit room
// avatar, the other member's avatar). Optional — empty for rooms
// without an avatar configured. Java side resolves via auth-media
// v1.11 + LRU bitmap cache.
avatarMxc?: string;
}
>;
/**
* user_id → MXC avatar URL. Bridged to the Java side so the FCM /
* WorkManager renderers can attach IconCompat icons to per-sender Person
* objects in MessagingStyle (and to the self-Person anchor). Senders not
* in this map fall through to Android's default initials/blank circle.
*/
export type UserAvatarsMap = Record<string, string>;
interface PollingPluginIface {
saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise<void>;
clearSession(): Promise<void>;
// Tolerant of both legacy (`roomId: "Display"`) and new structured shape
// (`roomId: { name, isDirect, isEncrypted, avatarMxc }`) — the Java side
// decides the message channel (DM vs group) and resolves the room large
// icon based on the structured value when present.
saveRoomNames(opts: { names: RoomMetadataMap }): Promise<void>;
saveUserAvatars(opts: { avatars: UserAvatarsMap }): Promise<void>;
schedule(opts: { intervalMinutes: number }): Promise<void>;
cancel(): Promise<void>;
dismissRoom(opts: { roomId: string }): Promise<void>;
}
const noopPlugin: PollingPluginIface = {
saveSession: async () => undefined,
clearSession: async () => undefined,
saveRoomNames: async () => undefined,
saveUserAvatars: async () => undefined,
schedule: async () => undefined,
cancel: async () => undefined,
dismissRoom: async () => undefined,
};
const plugin = registerPlugin<PollingPluginIface>('Polling', {
web: noopPlugin,
});
const guard = async <T>(fn: () => Promise<T>, fallback: T): Promise<T> => {
if (!isAndroidPlatform()) return fallback;
try {
return await fn();
} catch (err) {
// Old APK installed before the plugin shipped, or transient bridge
// error. Swallow — polling is a best-effort backup channel, not a
// hard dependency on the foreground push lifecycle.
// eslint-disable-next-line no-console
console.warn('[polling] native call failed:', err);
return fallback;
}
};
export const polling = {
saveSession: (opts: { accessToken: string; homeserverUrl: string; userId?: string }) =>
guard(() => plugin.saveSession(opts), undefined),
clearSession: () => guard(() => plugin.clearSession(), undefined),
saveRoomNames: (names: RoomMetadataMap) =>
guard(() => plugin.saveRoomNames({ names }), undefined),
saveUserAvatars: (avatars: UserAvatarsMap) =>
guard(() => plugin.saveUserAvatars({ avatars }), undefined),
schedule: (intervalMinutes = 15) => guard(() => plugin.schedule({ intervalMinutes }), undefined),
cancel: () => guard(() => plugin.cancel(), undefined),
dismissRoom: (roomId: string) => guard(() => plugin.dismissRoom({ roomId }), undefined),
};