Frontend: Basic notification receiving via SSE
ci/woodpecker/push/ociImagePush Pipeline is running Details

This commit is contained in:
Natty 2024-01-18 03:26:26 +01:00
parent 7b02f84271
commit f441de806f
Signed by: natty
GPG Key ID: BF6CB659ADEE60EC
16 changed files with 225 additions and 42 deletions

View File

@ -16,7 +16,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from "vue"; import { onMounted } from "vue";
import XNotification from "@/components/MkNotification.vue"; import XNotification from "@/components/MagNotification.vue";
import * as os from "@/os"; import * as os from "@/os";
defineProps<{ defineProps<{

View File

@ -44,10 +44,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import XNotification from "@/components/MkNotification.vue"; import XNotification from "@/components/MagNotification.vue";
import XList from "@/components/MkDateSeparatedList.vue"; import XList from "@/components/MkDateSeparatedList.vue";
import XNote from "@/components/MagNote.vue"; import XNote from "@/components/MagNote.vue";
import { stream } from "@/stream"; import { magStream, stream } from "@/stream";
import { $i } from "@/account"; import { $i } from "@/account";
import MagPagination, { Paging } from "@/components/MagPagination.vue"; import MagPagination, { Paging } from "@/components/MagPagination.vue";
import { endpoints, types } from "magnetar-common"; import { endpoints, types } from "magnetar-common";
@ -89,18 +89,20 @@ const onNotification = (notification) => {
} }
}; };
let notifStream;
let connection; let connection;
onMounted(() => { onMounted(() => {
notifStream = magStream.useFiltered("Notification", onNotification);
connection = stream.useChannel("main"); connection = stream.useChannel("main");
connection.on("notification", onNotification);
connection.on("readAllNotifications", () => { connection.on("readAllNotifications", () => {
if (pagingComponent.value) { if (pagingComponent.value) {
for (const item of pagingComponent.value.queue) { for (const item of pagingComponent.value.queue) {
item.isRead = true; item.is_read = true;
} }
for (const item of pagingComponent.value.items) { for (const item of pagingComponent.value.items) {
item.isRead = true; item.is_read = true;
} }
} }
}); });
@ -110,7 +112,7 @@ onMounted(() => {
if ( if (
notificationIds.includes(pagingComponent.value.queue[i].id) notificationIds.includes(pagingComponent.value.queue[i].id)
) { ) {
pagingComponent.value.queue[i].isRead = true; pagingComponent.value.queue[i].is_read = true;
} }
} }
for ( for (
@ -121,7 +123,7 @@ onMounted(() => {
if ( if (
notificationIds.includes(pagingComponent.value.items[i].id) notificationIds.includes(pagingComponent.value.items[i].id)
) { ) {
pagingComponent.value.items[i].isRead = true; pagingComponent.value.items[i].is_read = true;
} }
} }
} }

View File

@ -7,6 +7,7 @@ export const host = _HOST || address.host;
export const hostname = address.hostname; export const hostname = address.hostname;
export const url = _REMOTE_URL || address.origin; export const url = _REMOTE_URL || address.origin;
export const apiUrl = `${url}/api`; export const apiUrl = `${url}/api`;
export const magStreamingUrl = `${url}/mag/v1`;
export const feApiUrl = `${url}/fe-api`; export const feApiUrl = `${url}/fe-api`;
export const wsUrl = `${url export const wsUrl = `${url
.replace("http://", "ws://") .replace("http://", "ws://")

View File

@ -62,7 +62,7 @@ import { computed, ref, watch } from "vue";
import { Virtual } from "swiper"; import { Virtual } from "swiper";
import { Swiper, SwiperSlide } from "swiper/vue"; import { Swiper, SwiperSlide } from "swiper/vue";
import { notificationTypes } from "calckey-js"; import { notificationTypes } from "calckey-js";
import XNotifications from "@/components/MkNotifications.vue"; import XNotifications from "@/components/MagNotifications.vue";
import XNotes from "@/components/MkNotes.vue"; import XNotes from "@/components/MkNotes.vue";
import * as os from "@/os"; import * as os from "@/os";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";

View File

@ -1,7 +1,8 @@
import * as Misskey from "calckey-js"; import * as Misskey from "calckey-js";
import { markRaw } from "vue"; import { markRaw } from "vue";
import { $i } from "@/account"; import { $i } from "@/account";
import { url } from "@/config"; import { magStreamingUrl, url } from "@/config";
import { MagEventChannel } from "magnetar-common";
export const stream = markRaw( export const stream = markRaw(
new Misskey.Stream( new Misskey.Stream(
@ -22,3 +23,7 @@ function heartbeat(): void {
} }
window.setTimeout(heartbeat, 1000 * 60); window.setTimeout(heartbeat, 1000 * 60);
} }
export const magStream = markRaw(
new MagEventChannel(magStreamingUrl, $i ? $i.token : null)
);

View File

@ -19,11 +19,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent } from "vue"; import { defineAsyncComponent } from "vue";
import { swInject } from "./sw-inject"; import { swInject } from "./sw-inject";
import { popup, popups, pendingApiRequestsCount } from "@/os"; import { popup, popups } from "@/os";
import { uploads } from "@/scripts/upload"; import { uploads } from "@/scripts/upload";
import * as sound from "@/scripts/sound"; import * as sound from "@/scripts/sound";
import { $i } from "@/account"; import { $i } from "@/account";
import { stream } from "@/stream"; import { magStream, stream } from "@/stream";
import { PackNotification } from "magnetar-common/built/types/PackNotification";
const XStreamIndicator = defineAsyncComponent( const XStreamIndicator = defineAsyncComponent(
() => import("./stream-indicator.vue") () => import("./stream-indicator.vue")
@ -32,7 +33,7 @@ const XUpload = defineAsyncComponent(() => import("./upload.vue"));
const dev = _DEV_; const dev = _DEV_;
const onNotification = (notification) => { const onNotification = (notification: PackNotification) => {
if ($i.mutingNotificationTypes.includes(notification.type)) return; if ($i.mutingNotificationTypes.includes(notification.type)) return;
if (document.visibilityState === "visible") { if (document.visibilityState === "visible") {
@ -42,7 +43,7 @@ const onNotification = (notification) => {
popup( popup(
defineAsyncComponent( defineAsyncComponent(
() => import("@/components/MkNotificationToast.vue") () => import("@/components/MagNotificationToast.vue")
), ),
{ {
notification, notification,
@ -56,8 +57,7 @@ const onNotification = (notification) => {
}; };
if ($i) { if ($i) {
const connection = stream.useChannel("main", null, "UI"); const connection = magStream.useFiltered("Notification", onNotification);
connection.on("notification", onNotification);
//#region Listen message from SW //#region Listen message from SW
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {

View File

@ -17,9 +17,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent } from "vue"; import { defineAsyncComponent } from "vue";
import XColumn from "./column.vue"; import XColumn from "./column.vue";
import { updateColumn } from "./deck-store";
import type { Column } from "./deck-store"; import type { Column } from "./deck-store";
import XNotifications from "@/components/MkNotifications.vue"; import { updateColumn } from "./deck-store";
import XNotifications from "@/components/MagNotifications.vue";
import * as os from "@/os"; import * as os from "@/os";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";

View File

@ -26,16 +26,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent } from "vue"; import { defineAsyncComponent } from "vue";
import { import { useWidgetPropsManager, Widget, WidgetComponentExpose } from "./widget";
useWidgetPropsManager,
Widget,
WidgetComponentEmits,
WidgetComponentExpose,
WidgetComponentProps,
} from "./widget";
import { GetFormResultType } from "@/scripts/form"; import { GetFormResultType } from "@/scripts/form";
import MkContainer from "@/components/MkContainer.vue"; import MkContainer from "@/components/MkContainer.vue";
import XNotifications from "@/components/MkNotifications.vue"; import XNotifications from "@/components/MagNotifications.vue";
import * as os from "@/os"; import * as os from "@/os";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";

View File

@ -11,5 +11,8 @@
"description": "A library with common utilities for Magnetar application development", "description": "A library with common utilities for Magnetar application development",
"devDependencies": { "devDependencies": {
"typescript": "^5.1.6" "typescript": "^5.1.6"
},
"dependencies": {
"eventemitter3": "^5.0.1"
} }
} }

View File

@ -13,9 +13,11 @@ import {
FrontendApiEndpoints, FrontendApiEndpoints,
} from "./fe-api"; } from "./fe-api";
export * as types from "./types"; import { MagEventChannel } from "./sse-listener";
export * as packed from "./packed";
export * as endpoints from "./endpoints"; import * as types from "./types";
import * as packed from "./packed";
import * as endpoints from "./endpoints";
export { export {
Method, Method,
@ -27,4 +29,8 @@ export {
feEndpoints, feEndpoints,
FrontendApiEndpoint, FrontendApiEndpoint,
FrontendApiEndpoints, FrontendApiEndpoints,
MagEventChannel,
types,
packed,
endpoints,
}; };

View File

@ -0,0 +1,142 @@
import { EventEmitter } from "eventemitter3";
import { ChannelEvent } from "./types/ChannelEvent";
export type MagChannelState = "connected" | "exponentialBackoff" | "failed";
export class MagEventChannel extends EventEmitter<{
stateChange: MagChannelState;
message: ChannelEvent;
close: "cancelled";
}> {
private readonly baseUrl: string;
private attempts = 0;
private readonly maxAttempts: number;
private readonly token: string | null;
private readonly backoffFactor: number;
private readonly backoffBase: number;
private readonly closePromise: Promise<"cancelled">;
public constructor(
baseUrl: string,
token: string | null,
maxReconnectAttempts: number = 12,
backoffFactor: number = 1.618,
backoffBase: number = 500.0
) {
super();
this.baseUrl = baseUrl;
this.token = token;
this.maxAttempts = maxReconnectAttempts;
this.backoffFactor = backoffFactor;
this.backoffBase = backoffBase;
this.closePromise = new Promise((resolve) => {
this.on("close", resolve);
});
this.connect().then();
}
public useFiltered<T extends ChannelEvent["type"]>(
messageType: T,
listener: (val: (ChannelEvent & { type: T })["body"]) => void
) {
const cb = (val: ChannelEvent) => {
if (val.type != messageType) return;
listener((val as ChannelEvent & { type: T }).body);
};
this.on("message", cb);
return cb;
}
private async connect() {
if (this.attempts >= this.maxAttempts) {
this.emit("stateChange", "failed");
return;
}
const authorization = this.token ? `Bearer ${this.token}` : undefined;
const baseUrl = this.baseUrl.replace(/\/+$/, "");
const response = await fetch(`${baseUrl}/streaming`, {
method: "GET",
headers: authorization ? { authorization } : {},
credentials: "omit",
cache: "no-cache",
}).catch((e) => {
console.error(e);
return null;
});
if (
response === null ||
response.status >= 500 ||
response.body === null
) {
this.emit("stateChange", "exponentialBackoff");
setTimeout(
() => this.connect(),
this.backoffBase * Math.pow(this.backoffFactor, this.attempts)
);
this.attempts++;
return;
}
if (response.status >= 400 && response.status < 500) {
this.emit("stateChange", "failed");
return;
}
this.attempts = 0;
const decoderStream = new TextDecoderStream();
const reader = response.body.pipeThrough(decoderStream).getReader();
this.emit("stateChange", "connected");
let buf = "";
while (true) {
const res = await Promise.race([reader.read(), this.closePromise]);
if (res === "cancelled") break;
if (res.done) {
this.emit("stateChange", "exponentialBackoff");
setTimeout(
() => this.connect(),
this.backoffBase *
Math.pow(this.backoffFactor, this.attempts)
);
this.attempts++;
break;
}
buf += res.value;
const splitIndex = buf.indexOf("\n\n");
if (splitIndex === -1) {
continue;
}
const rawValue = buf.substring(0, splitIndex);
buf = buf.substring(splitIndex + 2);
if (rawValue.startsWith(":")) continue;
const text = rawValue
.split("\n")
.filter((l) => l.startsWith("data: "))
.map((l) => l.substring("data: ".length))
.join("\n");
if (!text) continue;
const data = JSON.parse(text) as ChannelEvent;
this.emit("message", data);
}
}
public async close() {
this.emit("close", "cancelled");
}
}

View File

@ -55,3 +55,4 @@ export { NotificationNoteExt } from "./types/NotificationNoteExt";
export { NotificationReactionExt } from "./types/NotificationReactionExt"; export { NotificationReactionExt } from "./types/NotificationReactionExt";
export { NotificationUserExt } from "./types/NotificationUserExt"; export { NotificationUserExt } from "./types/NotificationUserExt";
export { NotificationsReq } from "./types/NotificationsReq"; export { NotificationsReq } from "./types/NotificationsReq";
export { ChannelEvent } from "./types/ChannelEvent";

View File

@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PackNotification } from "./PackNotification";
export type ChannelEvent = { "type": "Notification", "body": PackNotification };

View File

@ -358,6 +358,10 @@ importers:
version: 4.1.0(vue@3.3.4) version: 4.1.0(vue@3.3.4)
magnetar-common: magnetar-common:
dependencies:
eventemitter3:
specifier: ^5.0.1
version: 5.0.1
devDependencies: devDependencies:
typescript: typescript:
specifier: ^5.1.6 specifier: ^5.1.6
@ -3269,6 +3273,10 @@ packages:
/eventemitter3@4.0.7: /eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
/eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
dev: false
/events@3.3.0: /events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'} engines: {node: '>=0.8.x'}

View File

@ -11,6 +11,7 @@ use futures_util::StreamExt as _;
use magnetar_calckey_model::model_ext::IdShape; use magnetar_calckey_model::model_ext::IdShape;
use magnetar_calckey_model::{CalckeySub, MainStreamMessage, SubMessage}; use magnetar_calckey_model::{CalckeySub, MainStreamMessage, SubMessage};
use magnetar_sdk::types::streaming::ChannelEvent; use magnetar_sdk::types::streaming::ChannelEvent;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@ -47,6 +48,11 @@ pub async fn handle_streaming(
}; };
if id != user_id { if id != user_id {
trace!(
"Skipping message intended for {} in channel {}",
id,
user_id
);
return; return;
} }
@ -59,11 +65,15 @@ pub async fn handle_streaming(
drop_on_close(sub, tx); drop_on_close(sub, tx);
let event_counter = Arc::new(AtomicU64::default());
let stream = ReceiverStream::new(rx).filter_map(move |m| { let stream = ReceiverStream::new(rx).filter_map(move |m| {
trace!("Processing raw message: {:?}", m);
let service = service.clone(); let service = service.clone();
let self_user = self_user.clone(); let self_user = self_user.clone();
let event_counter = event_counter.clone();
async move { async move {
match m { let message = match m {
MainStreamMessage::Notification(IdShape { id }) => { MainStreamMessage::Notification(IdShape { id }) => {
let ctx = PackingContext::new(service, Some(self_user.clone())) let ctx = PackingContext::new(service, Some(self_user.clone()))
.await .await
@ -75,20 +85,27 @@ pub async fn handle_streaming(
let notification_model = NotificationModel; let notification_model = NotificationModel;
Some( Some(
Event::default().json_data(ChannelEvent::Notification( Event::default()
notification_model .id(event_counter.fetch_add(1, Ordering::Relaxed).to_string())
.get_notification(&ctx, &id, &self_user.id) .event("message")
.await .json_data(ChannelEvent::Notification(
.map_err(|e| { notification_model
error!("Failed to fetch notification: {}", e); .get_notification(&ctx, &id, &self_user.id)
e .await
}) .map_err(|e| {
.ok() error!("Failed to fetch notification: {}", e);
.flatten()?, e
)), })
.ok()
.flatten()?,
)),
) )
} }
} };
trace!("Sending message: {:?}", message);
message
} }
}); });