You've already forked AstralRinth
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:
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[])
|
||||
",
|
||||
¬ification_ids[..],
|
||||
¬ification_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,
|
||||
¬ification_ids,
|
||||
¬ification_ids_raw,
|
||||
&users_raw_ids,
|
||||
¬ification_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(¬ification_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!(
|
||||
¬ification.body,
|
||||
NotificationBody::ServerInvite { .. }
|
||||
)
|
||||
})
|
||||
.map(Notification::from);
|
||||
|
||||
for notification in unread_server_invites {
|
||||
let _ = session.text(serde_json::to_string(¬ification)?).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,
|
||||
|
||||
@@ -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,
|
||||
¬ification,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1648,6 +1648,8 @@ export namespace Labrinth {
|
||||
message_id?: string
|
||||
invited_by?: string
|
||||
organization_id?: string
|
||||
server_id?: string
|
||||
server_name?: string
|
||||
team_id?: string
|
||||
role?: string
|
||||
old_status?: string
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::event::{
|
||||
LoadingPayload, ProcessPayload, ProfilePayload, WarningPayload,
|
||||
};
|
||||
use futures::prelude::*;
|
||||
use serde_json::Value;
|
||||
#[cfg(feature = "tauri")]
|
||||
use tauri::{Emitter, Manager};
|
||||
use uuid::Uuid;
|
||||
@@ -303,6 +304,20 @@ pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
|
||||
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!(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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::ErrorKind;
|
||||
use crate::data::ModrinthCredentials;
|
||||
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::{ProcessManager, Profile, TunnelSocket};
|
||||
use crate::util::fetch::{FetchSemaphore, fetch_advanced, fetch_json};
|
||||
@@ -22,6 +22,7 @@ use futures::{SinkExt, StreamExt};
|
||||
use reqwest::Method;
|
||||
use reqwest::header::HeaderValue;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::net::SocketAddr;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
@@ -120,16 +121,34 @@ impl FriendsSocket {
|
||||
Ok(msg) => {
|
||||
let server_message = match msg {
|
||||
Message::Text(text) => {
|
||||
ServerToClientMessage::deserialize(
|
||||
match ServerToClientMessage::deserialize(
|
||||
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) => {
|
||||
ServerToClientMessage::deserialize(
|
||||
match ServerToClientMessage::deserialize(
|
||||
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) => {
|
||||
if let Some(write) = write_handle
|
||||
@@ -224,6 +243,19 @@ impl FriendsSocket {
|
||||
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)]
|
||||
pub async fn socket_loop() -> crate::Result<()> {
|
||||
let state = crate::State::get().await?;
|
||||
|
||||
@@ -18,28 +18,35 @@
|
||||
>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'text-red': item.type === 'error',
|
||||
'text-orange': item.type === 'warning',
|
||||
'text-green': item.type === 'download',
|
||||
'text-contrast': item.type === 'success',
|
||||
'text-blue':
|
||||
!item.type ||
|
||||
!['error', 'warning', 'success', 'download'].includes(item.type),
|
||||
}"
|
||||
>
|
||||
<IssuesIcon v-if="item.type === 'warning'" class="h-5 w-5" />
|
||||
<DownloadIcon v-else-if="item.type === 'download'" class="h-5 w-5" />
|
||||
<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>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<component
|
||||
:is="item.titleLogo"
|
||||
v-if="item.titleLogo"
|
||||
class="h-7 w-auto min-w-0 max-w-full text-contrast"
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'text-red': item.type === 'error',
|
||||
'text-orange': item.type === 'warning',
|
||||
'text-green': item.type === 'download',
|
||||
'text-contrast': item.type === 'success',
|
||||
'text-blue':
|
||||
!item.type ||
|
||||
!['error', 'warning', 'success', 'download'].includes(item.type),
|
||||
}"
|
||||
>
|
||||
<IssuesIcon v-if="item.type === 'warning'" class="h-5 w-5" />
|
||||
<DownloadIcon v-else-if="item.type === 'download'" class="h-5 w-5" />
|
||||
<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>
|
||||
<ButtonStyled size="small" type="transparent" circular>
|
||||
<button @click="dismiss(item.id)">
|
||||
@@ -50,6 +57,11 @@
|
||||
<span v-if="item.text" class="text-primary">
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<component
|
||||
:is="item.bodyComponent"
|
||||
v-if="item.bodyComponent"
|
||||
v-bind="item.bodyProps ?? {}"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="item.progressItems?.length" class="flex flex-col gap-3">
|
||||
<div
|
||||
@@ -89,6 +101,7 @@
|
||||
:color="btn.color || (idx === 0 ? 'brand' : undefined)"
|
||||
>
|
||||
<button @click="handleButtonClick(item.id, btn)">
|
||||
<component :is="btn.icon" v-if="btn.icon" />
|
||||
{{ btn.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -166,7 +179,8 @@ withDefaults(
|
||||
top: calc(var(--top-bar-height, 3rem) + 1.5rem);
|
||||
right: 1.5rem;
|
||||
z-index: 200;
|
||||
width: 400px;
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 3rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
@@ -179,6 +193,7 @@ withDefaults(
|
||||
@media screen and (max-width: 500px) {
|
||||
.popup-notification-group {
|
||||
width: calc(100% - 1.5rem);
|
||||
max-width: none;
|
||||
right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export * from './icons'
|
||||
export { default as InstallingBanner } from './InstallingBanner.vue'
|
||||
export * from './labels'
|
||||
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 * from './server-header'
|
||||
export { default as ServerListEmpty } from './server-list-empty/ServerListEmpty.vue'
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { createContext } from '.'
|
||||
|
||||
export interface PopupNotificationButton {
|
||||
label: string
|
||||
action: () => void
|
||||
icon?: Component
|
||||
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'standard'
|
||||
keepOpen?: boolean
|
||||
}
|
||||
@@ -18,6 +21,9 @@ export interface PopupNotificationProgressItem {
|
||||
export interface PopupNotification {
|
||||
id: string | number
|
||||
title: string
|
||||
titleLogo?: Component
|
||||
bodyComponent?: Component
|
||||
bodyProps?: Record<string, unknown>
|
||||
text?: string
|
||||
type?: 'error' | 'warning' | 'success' | 'info' | 'download'
|
||||
progress?: number
|
||||
|
||||
Reference in New Issue
Block a user