feat: add notifs onto friends ws temporarily (#6290)

* feat: add notifs onto friends ws temporarily

* fix: lint + styling

* fix: regressions
This commit is contained in:
Calum H.
2026-06-02 20:47:37 +01:00
committed by GitHub
parent 940a796ba5
commit 3c051f5b1d
14 changed files with 369 additions and 45 deletions
+93 -1
View File
@@ -11,6 +11,7 @@ import {
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
CheckIcon,
CompassIcon,
DownloadIcon,
ExternalIcon,
@@ -40,6 +41,7 @@ import {
defineMessages,
I18nDebugPanel,
LoadingBar,
ModrinthHostingLogo,
NewsArticleCard,
NotificationPanel,
OverflowMenu,
@@ -84,6 +86,7 @@ import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import ServerInvitePopupBody from '@/components/ui/notifications/ServerInvitePopupBody.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
@@ -95,7 +98,7 @@ import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { create_profile_and_install_from_file } from '@/helpers/pack'
import { list } from '@/helpers/profile.js'
@@ -241,6 +244,7 @@ const {
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -752,6 +756,94 @@ const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand)
notification_listener(handleLiveNotification)
async function markLiveNotificationRead(notification) {
try {
await tauriApiClient.labrinth.notifications_v2.markAsRead(notification.id)
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 404) {
console.warn(`notification ${notification.id} could not be marked as read`, error)
return
}
throw error
}
}
async function respondToServerInvite(notification, action) {
const serverId = notification.body?.server_id
if (typeof serverId !== 'string') {
throw new Error('Missing server ID for invite notification.')
}
await tauriApiClient.request(`/servers/${serverId}/invites/${action}`, {
api: 'archon',
version: 1,
method: 'POST',
})
await markLiveNotificationRead(notification)
return serverId
}
async function acceptServerInviteNotification(notification) {
try {
const serverId = await respondToServerInvite(notification, 'accept')
await router.push(`/hosting/manage/${encodeURIComponent(serverId)}`)
queryClient.invalidateQueries({ queryKey: ['servers'] })
} catch (error) {
handleError(error)
}
}
async function declineServerInviteNotification(notification) {
try {
await respondToServerInvite(notification, 'decline')
} catch (error) {
handleError(error)
}
}
async function handleLiveNotification(notification) {
if (notification?.body?.type !== 'server_invite' || notification.read) return
if (displayedServerInviteNotifications.has(notification.id)) return
displayedServerInviteNotifications.add(notification.id)
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
addPopupNotification({
title: 'Modrinth Hosting',
titleLogo: ModrinthHostingLogo,
bodyComponent: ServerInvitePopupBody,
bodyProps: {
inviterName: invitedBy?.username ?? null,
inviterAvatarUrl: invitedBy?.avatar_url ?? null,
serverName,
},
type: 'info',
buttons: [
{
label: 'Accept',
action: () => acceptServerInviteNotification(notification),
icon: CheckIcon,
color: 'brand',
},
{
label: 'Decline',
action: () => declineServerInviteNotification(notification),
icon: XIcon,
color: 'red',
},
],
autoCloseMs: null,
})
}
async function handleCommand(e) {
if (!e) return
@@ -0,0 +1,52 @@
<template>
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1 leading-snug text-primary">
<button
v-if="inviterName"
type="button"
class="inline-flex min-w-0 items-center border-0 bg-transparent p-0 font-semibold text-contrast hover:underline"
@click="openInviterProfile(inviterName)"
>
<Avatar
:src="inviterAvatarUrl"
:alt="inviterName"
circle
size="xxs"
no-shadow
class="mr-1.5 inline-flex"
/>
<span>{{ inviterName }}</span>
</button>
<span>
<span v-if="inviterName" class="whitespace-nowrap">has invited you to manage</span>
<span v-else class="whitespace-nowrap">You have been invited to manage</span>
<span class="font-semibold text-contrast ml-1">{{ serverName }}</span>
<span>.</span>
</span>
</div>
</template>
<script setup>
import { Avatar } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { config } from '@/config'
defineProps({
inviterName: {
type: String,
default: null,
},
inviterAvatarUrl: {
type: String,
default: null,
},
serverName: {
type: String,
required: true,
},
})
function openInviterProfile(username) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`)
}
</script>
+4
View File
@@ -98,6 +98,10 @@ export async function friend_listener(callback) {
return await listen('friend', (event) => callback(event.payload))
}
export async function notification_listener(callback) {
return await listen('notification', (event) => callback(event.payload))
}
/// Payload for the 'log' event
/*
LogPayload {
+6
View File
@@ -42,6 +42,12 @@
{
"url": "https://api.purpurmc.org/*"
},
{
"url": "http://localhost:8000/*"
},
{
"url": "http://127.0.0.1:8000/*"
},
{
"url": "http://*.taila228c5.ts.net/*"
},
@@ -40,7 +40,8 @@ impl NotificationBuilder {
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<(), DatabaseError> {
self.insert_many(vec![user], transaction, redis).await
self.insert_many(vec![user], transaction, redis).await?;
Ok(())
}
pub async fn insert_many_payout_notifications(
@@ -133,7 +134,7 @@ impl NotificationBuilder {
&self,
users: &[DBUserId],
transaction: &mut PgTransaction<'_>,
) -> Result<Vec<i64>, DatabaseError> {
) -> Result<Vec<DBNotificationId>, DatabaseError> {
let notification_ids =
generate_many_notification_ids(users.len(), &mut *transaction)
.await?;
@@ -145,7 +146,7 @@ impl NotificationBuilder {
.collect::<Vec<_>>();
let users_raw_ids = users.iter().map(|x| x.0).collect::<Vec<_>>();
let notification_ids =
let notification_ids_raw =
notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
sqlx::query!(
@@ -155,7 +156,7 @@ impl NotificationBuilder {
)
SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::jsonb[])
",
&notification_ids[..],
&notification_ids_raw[..],
&users_raw_ids[..],
&bodies[..],
)
@@ -170,11 +171,13 @@ impl NotificationBuilder {
users: Vec<DBUserId>,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<(), DatabaseError> {
) -> Result<Vec<DBNotificationId>, DatabaseError> {
let notification_ids =
self.insert_many_records(&users, transaction).await?;
let users_raw_ids = users.iter().map(|x| x.0).collect::<Vec<_>>();
let notification_ids_raw =
notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
let notification_types = notification_ids
.iter()
@@ -184,14 +187,14 @@ impl NotificationBuilder {
NotificationBuilder::insert_many_deliveries(
transaction,
redis,
&notification_ids,
&notification_ids_raw,
&users_raw_ids,
&notification_types,
&users,
)
.await?;
Ok(())
Ok(notification_ids)
}
/// Like [`insert_many`], but skips queuing deliveries so the caller can
@@ -7,12 +7,14 @@ use crate::database::models::user_item::DBUser;
use crate::database::redis::RedisPool;
use crate::models::users::Role;
use crate::models::v3::notifications::{
NotificationBody, NotificationDeliveryStatus,
Notification, NotificationBody, NotificationDeliveryStatus,
};
use crate::models::v3::pats::Scopes;
use crate::queue::email::EmailQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::internal::statuses::broadcast_friends_message;
use crate::sync::friends::RedisFriendsMessage;
use crate::util::guards::external_notification_key_guard;
use actix_web::http::StatusCode;
use actix_web::web;
@@ -58,12 +60,39 @@ pub async fn create(
));
}
NotificationBuilder { body }
let notification_ids = NotificationBuilder { body }
.insert_many(user_ids, &mut txn, &redis)
.await?;
let notifications = DBNotification::get_many(&notification_ids, &mut txn)
.await?
.into_iter()
.map(Notification::from)
.collect::<Vec<_>>();
txn.commit().await?;
for notification in notifications {
let notification_id = notification.id;
let to_user = notification.user_id;
if let Err(error) = broadcast_friends_message(
&redis,
RedisFriendsMessage::Notification {
to_user,
notification,
},
)
.await
{
tracing::warn!(
?error,
?notification_id,
?to_user,
"failed to broadcast realtime notification"
);
}
}
Ok(HttpResponse::Accepted().finish())
}
@@ -2,7 +2,9 @@ use crate::auth::AuthenticationError;
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::database::PgPool;
use crate::database::models::friend_item::DBFriend;
use crate::database::models::notification_item::DBNotification;
use crate::database::redis::RedisPool;
use crate::models::notifications::{Notification, NotificationBody};
use crate::models::pats::Scopes;
use crate::models::users::User;
use crate::queue::session::AuthQueue;
@@ -42,6 +44,7 @@ struct LauncherHeartbeatInit {
code: String,
}
// TODO: Move launcher-specific tunnel traffic to a proper launcher websocket endpoint.
#[get("launcher_socket")]
pub async fn ws_init(
req: HttpRequest,
@@ -127,6 +130,26 @@ pub async fn ws_init(
)?)
.await;
let unread_server_invites = DBNotification::get_many_user_exposed_on_site(
user_id.into(),
&**pool,
&redis,
)
.await?
.into_iter()
.filter(|notification| {
!notification.read
&& matches!(
&notification.body,
NotificationBody::ServerInvite { .. }
)
})
.map(Notification::from);
for notification in unread_server_invites {
let _ = session.text(serde_json::to_string(&notification)?).await;
}
let db = db.clone();
let socket_id = db.next_socket_id.fetch_add(1, Ordering::Relaxed);
db.sockets
@@ -449,6 +472,25 @@ pub async fn send_message_to_user(
Ok(())
}
pub async fn send_notification_to_user(
db: &ActiveSockets,
user: UserId,
notification: &Notification,
) -> Result<(), crate::database::models::DatabaseError> {
let message = serde_json::to_string(notification)?;
if let Some(socket_ids) = db.sockets_by_user_id.get(&user) {
for socket_id in socket_ids.iter() {
if let Some(socket) = db.sockets.get(&socket_id) {
let mut socket = socket.socket.clone();
let _ = socket.text(message.clone()).await;
}
}
}
Ok(())
}
pub async fn close_socket(
id: SocketId,
pool: &PgPool,
+29 -5
View File
@@ -1,7 +1,8 @@
use crate::database::PgPool;
use crate::models::notifications::Notification;
use crate::queue::socket::ActiveSockets;
use crate::routes::internal::statuses::{
broadcast_to_local_friends, send_message_to_user,
broadcast_to_local_friends, send_message_to_user, send_notification_to_user,
};
use actix_web::web::Data;
use ariadne::ids::UserId;
@@ -14,12 +15,23 @@ use tokio_stream::StreamExt;
pub const FRIENDS_CHANNEL_NAME: &str = "friends";
#[derive(Debug, Serialize, Deserialize)]
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RedisFriendsMessage {
StatusUpdate { status: UserStatus },
UserOffline { user: UserId },
DirectStatusUpdate { to_user: UserId, status: UserStatus },
StatusUpdate {
status: UserStatus,
},
UserOffline {
user: UserId,
},
DirectStatusUpdate {
to_user: UserId,
status: UserStatus,
},
Notification {
to_user: UserId,
notification: Notification,
},
}
impl ToRedisArgs for RedisFriendsMessage {
@@ -80,6 +92,18 @@ pub async fn handle_pubsub(
.await;
}
Ok(RedisFriendsMessage::Notification {
to_user,
notification,
}) => {
let _ = send_notification_to_user(
&sockets,
to_user,
&notification,
)
.await;
}
Err(_) => {}
}
});