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 19:47:37 +00: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 { import {
ArrowBigUpDashIcon, ArrowBigUpDashIcon,
ChangeSkinIcon, ChangeSkinIcon,
CheckIcon,
CompassIcon, CompassIcon,
DownloadIcon, DownloadIcon,
ExternalIcon, ExternalIcon,
@@ -40,6 +41,7 @@ import {
defineMessages, defineMessages,
I18nDebugPanel, I18nDebugPanel,
LoadingBar, LoadingBar,
ModrinthHostingLogo,
NewsArticleCard, NewsArticleCard,
NotificationPanel, NotificationPanel,
OverflowMenu, OverflowMenu,
@@ -84,6 +86,7 @@ import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue' import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue' import NavButton from '@/components/ui/NavButton.vue'
import ServerInvitePopupBody from '@/components/ui/notifications/ServerInvitePopupBody.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue' import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.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 { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js' import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.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 { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { create_profile_and_install_from_file } from '@/helpers/pack' import { create_profile_and_install_from_file } from '@/helpers/pack'
import { list } from '@/helpers/profile.js' import { list } from '@/helpers/profile.js'
@@ -241,6 +244,7 @@ const {
const news = ref([]) const news = ref([])
const availableSurvey = ref(false) const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const offline = ref(!navigator.onLine) const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
@@ -752,6 +756,94 @@ const accounts = ref(null)
provide('accountsCard', accounts) provide('accountsCard', accounts)
command_listener(handleCommand) 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) { async function handleCommand(e) {
if (!e) return 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)) 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 /// Payload for the 'log' event
/* /*
LogPayload { LogPayload {
+6
View File
@@ -42,6 +42,12 @@
{ {
"url": "https://api.purpurmc.org/*" "url": "https://api.purpurmc.org/*"
}, },
{
"url": "http://localhost:8000/*"
},
{
"url": "http://127.0.0.1:8000/*"
},
{ {
"url": "http://*.taila228c5.ts.net/*" "url": "http://*.taila228c5.ts.net/*"
}, },
@@ -40,7 +40,8 @@ impl NotificationBuilder {
transaction: &mut PgTransaction<'_>, transaction: &mut PgTransaction<'_>,
redis: &RedisPool, redis: &RedisPool,
) -> Result<(), DatabaseError> { ) -> 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( pub async fn insert_many_payout_notifications(
@@ -133,7 +134,7 @@ impl NotificationBuilder {
&self, &self,
users: &[DBUserId], users: &[DBUserId],
transaction: &mut PgTransaction<'_>, transaction: &mut PgTransaction<'_>,
) -> Result<Vec<i64>, DatabaseError> { ) -> Result<Vec<DBNotificationId>, DatabaseError> {
let notification_ids = let notification_ids =
generate_many_notification_ids(users.len(), &mut *transaction) generate_many_notification_ids(users.len(), &mut *transaction)
.await?; .await?;
@@ -145,7 +146,7 @@ impl NotificationBuilder {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let users_raw_ids = users.iter().map(|x| x.0).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<_>>(); notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
sqlx::query!( sqlx::query!(
@@ -155,7 +156,7 @@ impl NotificationBuilder {
) )
SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::jsonb[]) SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::jsonb[])
", ",
&notification_ids[..], &notification_ids_raw[..],
&users_raw_ids[..], &users_raw_ids[..],
&bodies[..], &bodies[..],
) )
@@ -170,11 +171,13 @@ impl NotificationBuilder {
users: Vec<DBUserId>, users: Vec<DBUserId>,
transaction: &mut PgTransaction<'_>, transaction: &mut PgTransaction<'_>,
redis: &RedisPool, redis: &RedisPool,
) -> Result<(), DatabaseError> { ) -> Result<Vec<DBNotificationId>, DatabaseError> {
let notification_ids = let notification_ids =
self.insert_many_records(&users, transaction).await?; self.insert_many_records(&users, transaction).await?;
let users_raw_ids = users.iter().map(|x| x.0).collect::<Vec<_>>(); 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 let notification_types = notification_ids
.iter() .iter()
@@ -184,14 +187,14 @@ impl NotificationBuilder {
NotificationBuilder::insert_many_deliveries( NotificationBuilder::insert_many_deliveries(
transaction, transaction,
redis, redis,
&notification_ids, &notification_ids_raw,
&users_raw_ids, &users_raw_ids,
&notification_types, &notification_types,
&users, &users,
) )
.await?; .await?;
Ok(()) Ok(notification_ids)
} }
/// Like [`insert_many`], but skips queuing deliveries so the caller can /// 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::database::redis::RedisPool;
use crate::models::users::Role; use crate::models::users::Role;
use crate::models::v3::notifications::{ use crate::models::v3::notifications::{
NotificationBody, NotificationDeliveryStatus, Notification, NotificationBody, NotificationDeliveryStatus,
}; };
use crate::models::v3::pats::Scopes; use crate::models::v3::pats::Scopes;
use crate::queue::email::EmailQueue; use crate::queue::email::EmailQueue;
use crate::queue::session::AuthQueue; use crate::queue::session::AuthQueue;
use crate::routes::ApiError; 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 crate::util::guards::external_notification_key_guard;
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::web; 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) .insert_many(user_ids, &mut txn, &redis)
.await?; .await?;
let notifications = DBNotification::get_many(&notification_ids, &mut txn)
.await?
.into_iter()
.map(Notification::from)
.collect::<Vec<_>>();
txn.commit().await?; 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()) Ok(HttpResponse::Accepted().finish())
} }
@@ -2,7 +2,9 @@ use crate::auth::AuthenticationError;
use crate::auth::validate::get_user_record_from_bearer_token; use crate::auth::validate::get_user_record_from_bearer_token;
use crate::database::PgPool; use crate::database::PgPool;
use crate::database::models::friend_item::DBFriend; use crate::database::models::friend_item::DBFriend;
use crate::database::models::notification_item::DBNotification;
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::models::notifications::{Notification, NotificationBody};
use crate::models::pats::Scopes; use crate::models::pats::Scopes;
use crate::models::users::User; use crate::models::users::User;
use crate::queue::session::AuthQueue; use crate::queue::session::AuthQueue;
@@ -42,6 +44,7 @@ struct LauncherHeartbeatInit {
code: String, code: String,
} }
// TODO: Move launcher-specific tunnel traffic to a proper launcher websocket endpoint.
#[get("launcher_socket")] #[get("launcher_socket")]
pub async fn ws_init( pub async fn ws_init(
req: HttpRequest, req: HttpRequest,
@@ -127,6 +130,26 @@ pub async fn ws_init(
)?) )?)
.await; .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 db = db.clone();
let socket_id = db.next_socket_id.fetch_add(1, Ordering::Relaxed); let socket_id = db.next_socket_id.fetch_add(1, Ordering::Relaxed);
db.sockets db.sockets
@@ -449,6 +472,25 @@ pub async fn send_message_to_user(
Ok(()) 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( pub async fn close_socket(
id: SocketId, id: SocketId,
pool: &PgPool, pool: &PgPool,
+29 -5
View File
@@ -1,7 +1,8 @@
use crate::database::PgPool; use crate::database::PgPool;
use crate::models::notifications::Notification;
use crate::queue::socket::ActiveSockets; use crate::queue::socket::ActiveSockets;
use crate::routes::internal::statuses::{ 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 actix_web::web::Data;
use ariadne::ids::UserId; use ariadne::ids::UserId;
@@ -14,12 +15,23 @@ use tokio_stream::StreamExt;
pub const FRIENDS_CHANNEL_NAME: &str = "friends"; pub const FRIENDS_CHANNEL_NAME: &str = "friends";
#[derive(Debug, Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum RedisFriendsMessage { pub enum RedisFriendsMessage {
StatusUpdate { status: UserStatus }, StatusUpdate {
UserOffline { user: UserId }, status: UserStatus,
DirectStatusUpdate { to_user: UserId, status: UserStatus }, },
UserOffline {
user: UserId,
},
DirectStatusUpdate {
to_user: UserId,
status: UserStatus,
},
Notification {
to_user: UserId,
notification: Notification,
},
} }
impl ToRedisArgs for RedisFriendsMessage { impl ToRedisArgs for RedisFriendsMessage {
@@ -80,6 +92,18 @@ pub async fn handle_pubsub(
.await; .await;
} }
Ok(RedisFriendsMessage::Notification {
to_user,
notification,
}) => {
let _ = send_notification_to_user(
&sockets,
to_user,
&notification,
)
.await;
}
Err(_) => {} Err(_) => {}
} }
}); });
@@ -1648,6 +1648,8 @@ export namespace Labrinth {
message_id?: string message_id?: string
invited_by?: string invited_by?: string
organization_id?: string organization_id?: string
server_id?: string
server_name?: string
team_id?: string team_id?: string
role?: string role?: string
old_status?: string old_status?: string
+15
View File
@@ -8,6 +8,7 @@ use crate::event::{
LoadingPayload, ProcessPayload, ProfilePayload, WarningPayload, LoadingPayload, ProcessPayload, ProfilePayload, WarningPayload,
}; };
use futures::prelude::*; use futures::prelude::*;
use serde_json::Value;
#[cfg(feature = "tauri")] #[cfg(feature = "tauri")]
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
use uuid::Uuid; use uuid::Uuid;
@@ -303,6 +304,20 @@ pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
Ok(()) Ok(())
} }
#[allow(unused_variables)]
pub async fn emit_notification(payload: Value) -> crate::Result<()> {
#[cfg(feature = "tauri")]
{
let event_state = crate::EventState::get()?;
event_state
.app
.emit("notification", payload)
.map_err(EventError::from)?;
}
Ok(())
}
// loading_join! macro // loading_join! macro
// loading_join!(key: Option<&LoadingBarId>, total: f64, message: Option<&str>; task1, task2, task3...) // loading_join!(key: Option<&LoadingBarId>, total: f64, message: Option<&str>; task1, task2, task3...)
// This will submit a loading event with the given message for each task as they complete // This will submit a loading event with the given message for each task as they complete
+39 -7
View File
@@ -1,7 +1,7 @@
use crate::ErrorKind; use crate::ErrorKind;
use crate::data::ModrinthCredentials; use crate::data::ModrinthCredentials;
use crate::event::FriendPayload; use crate::event::FriendPayload;
use crate::event::emit::emit_friend; use crate::event::emit::{emit_friend, emit_notification};
use crate::state::tunnel::InternalTunnelSocket; use crate::state::tunnel::InternalTunnelSocket;
use crate::state::{ProcessManager, Profile, TunnelSocket}; use crate::state::{ProcessManager, Profile, TunnelSocket};
use crate::util::fetch::{FetchSemaphore, fetch_advanced, fetch_json}; use crate::util::fetch::{FetchSemaphore, fetch_advanced, fetch_json};
@@ -22,6 +22,7 @@ use futures::{SinkExt, StreamExt};
use reqwest::Method; use reqwest::Method;
use reqwest::header::HeaderValue; use reqwest::header::HeaderValue;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::ops::Deref; use std::ops::Deref;
use std::sync::Arc; use std::sync::Arc;
@@ -120,16 +121,34 @@ impl FriendsSocket {
Ok(msg) => { Ok(msg) => {
let server_message = match msg { let server_message = match msg {
Message::Text(text) => { Message::Text(text) => {
ServerToClientMessage::deserialize( match ServerToClientMessage::deserialize(
Either::Left(&text), Either::Left(&text),
) ) {
.ok() Ok(message) => Some(message),
Err(_) => {
if let Ok(notification) =
serde_json::from_str::<Value>(&text)
{
let _ = Self::handle_notification(notification).await;
}
None
}
}
} }
Message::Binary(bytes) => { Message::Binary(bytes) => {
ServerToClientMessage::deserialize( match ServerToClientMessage::deserialize(
Either::Right(&bytes), Either::Right(&bytes),
) ) {
.ok() Ok(message) => Some(message),
Err(_) => {
if let Ok(notification) =
serde_json::from_slice::<Value>(&bytes)
{
let _ = Self::handle_notification(notification).await;
}
None
}
}
} }
Message::Ping(bytes) => { Message::Ping(bytes) => {
if let Some(write) = write_handle if let Some(write) = write_handle
@@ -224,6 +243,19 @@ impl FriendsSocket {
Ok(()) Ok(())
} }
async fn handle_notification(notification: Value) -> crate::Result<()> {
if notification
.get("body")
.and_then(|body| body.get("type"))
.and_then(Value::as_str)
.is_some()
{
emit_notification(notification).await?;
}
Ok(())
}
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub async fn socket_loop() -> crate::Result<()> { pub async fn socket_loop() -> crate::Result<()> {
let state = crate::State::get().await?; let state = crate::State::get().await?;
@@ -18,28 +18,35 @@
> >
<div class="flex flex-col gap-2 w-full"> <div class="flex flex-col gap-2 w-full">
<div class="flex items-center justify-between gap-2.5"> <div class="flex items-center justify-between gap-2.5">
<div class="flex items-center gap-2"> <div class="flex min-w-0 flex-1 items-center gap-2">
<div <component
class="flex items-center" :is="item.titleLogo"
:class="{ v-if="item.titleLogo"
'text-red': item.type === 'error', class="h-7 w-auto min-w-0 max-w-full text-contrast"
'text-orange': item.type === 'warning', />
'text-green': item.type === 'download', <template v-else>
'text-contrast': item.type === 'success', <div
'text-blue': class="flex items-center"
!item.type || :class="{
!['error', 'warning', 'success', 'download'].includes(item.type), 'text-red': item.type === 'error',
}" 'text-orange': item.type === 'warning',
> 'text-green': item.type === 'download',
<IssuesIcon v-if="item.type === 'warning'" class="h-5 w-5" /> 'text-contrast': item.type === 'success',
<DownloadIcon v-else-if="item.type === 'download'" class="h-5 w-5" /> 'text-blue':
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-5 w-5" /> !item.type ||
<XCircleIcon v-else-if="item.type === 'error'" class="h-5 w-5" /> !['error', 'warning', 'success', 'download'].includes(item.type),
<InfoIcon v-else class="h-5 w-5" /> }"
</div> >
<div class="text-contrast font-semibold m-0 grow"> <IssuesIcon v-if="item.type === 'warning'" class="h-5 w-5" />
{{ item.title }} <DownloadIcon v-else-if="item.type === 'download'" class="h-5 w-5" />
</div> <CheckCircleIcon v-else-if="item.type === 'success'" class="h-5 w-5" />
<XCircleIcon v-else-if="item.type === 'error'" class="h-5 w-5" />
<InfoIcon v-else class="h-5 w-5" />
</div>
<div class="text-contrast font-semibold m-0 grow">
{{ item.title }}
</div>
</template>
</div> </div>
<ButtonStyled size="small" type="transparent" circular> <ButtonStyled size="small" type="transparent" circular>
<button @click="dismiss(item.id)"> <button @click="dismiss(item.id)">
@@ -50,6 +57,11 @@
<span v-if="item.text" class="text-primary"> <span v-if="item.text" class="text-primary">
{{ item.text }} {{ item.text }}
</span> </span>
<component
:is="item.bodyComponent"
v-if="item.bodyComponent"
v-bind="item.bodyProps ?? {}"
/>
</div> </div>
<div v-if="item.progressItems?.length" class="flex flex-col gap-3"> <div v-if="item.progressItems?.length" class="flex flex-col gap-3">
<div <div
@@ -89,6 +101,7 @@
:color="btn.color || (idx === 0 ? 'brand' : undefined)" :color="btn.color || (idx === 0 ? 'brand' : undefined)"
> >
<button @click="handleButtonClick(item.id, btn)"> <button @click="handleButtonClick(item.id, btn)">
<component :is="btn.icon" v-if="btn.icon" />
{{ btn.label }} {{ btn.label }}
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -166,7 +179,8 @@ withDefaults(
top: calc(var(--top-bar-height, 3rem) + 1.5rem); top: calc(var(--top-bar-height, 3rem) + 1.5rem);
right: 1.5rem; right: 1.5rem;
z-index: 200; z-index: 200;
width: 400px; width: 520px;
max-width: calc(100vw - 3rem);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
@@ -179,6 +193,7 @@ withDefaults(
@media screen and (max-width: 500px) { @media screen and (max-width: 500px) {
.popup-notification-group { .popup-notification-group {
width: calc(100% - 1.5rem); width: calc(100% - 1.5rem);
max-width: none;
right: 0.75rem; right: 0.75rem;
} }
} }
@@ -5,6 +5,8 @@ export * from './icons'
export { default as InstallingBanner } from './InstallingBanner.vue' export { default as InstallingBanner } from './InstallingBanner.vue'
export * from './labels' export * from './labels'
export * from './marketing' export * from './marketing'
export { default as ModrinthHostingLogo } from './ModrinthServersIcon.vue'
export { default as ModrinthServersIcon } from './ModrinthServersIcon.vue'
export { default as SaveBanner } from './SaveBanner.vue' export { default as SaveBanner } from './SaveBanner.vue'
export * from './server-header' export * from './server-header'
export { default as ServerListEmpty } from './server-list-empty/ServerListEmpty.vue' export { default as ServerListEmpty } from './server-list-empty/ServerListEmpty.vue'
@@ -1,8 +1,11 @@
import type { Component } from 'vue'
import { createContext } from '.' import { createContext } from '.'
export interface PopupNotificationButton { export interface PopupNotificationButton {
label: string label: string
action: () => void action: () => void
icon?: Component
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'standard' color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'standard'
keepOpen?: boolean keepOpen?: boolean
} }
@@ -18,6 +21,9 @@ export interface PopupNotificationProgressItem {
export interface PopupNotification { export interface PopupNotification {
id: string | number id: string | number
title: string title: string
titleLogo?: Component
bodyComponent?: Component
bodyProps?: Record<string, unknown>
text?: string text?: string
type?: 'error' | 'warning' | 'success' | 'info' | 'download' type?: 'error' | 'warning' | 'success' | 'info' | 'download'
progress?: number progress?: number