Skip to content

Conversation

IMB11
Copy link
Member

@IMB11 IMB11 commented Jul 30, 2025

  • Migrate notification management into a AbstractNotificationManager class which can be extended upon by the frontend and app-frontend.
  • Migrate frontend notifications from $notify and old addNotification composable to the new DI method.
  • Migrate frontend org context inject/provide DI to new system.
  • Migrate app-frontend notifications to new DI method.

@IMB11 IMB11 requested a review from Prospector July 30, 2025 11:52
@IMB11 IMB11 marked this pull request as ready for review July 31, 2025 12:26
@IMB11 IMB11 added enhancement New feature or request web Relates to Modrinth.com web frontend app Relates to Modrinth App frontend labels Jul 31, 2025
@IMB11
Copy link
Member Author

IMB11 commented Jul 31, 2025

Relevant files to review; rest is impl changes.

import { createContext } from '.'
export interface WebNotification {
id: string | number
title?: string
text?: string
type?: 'error' | 'warning' | 'success' | 'info'
errorCode?: string
count?: number
timer?: NodeJS.Timeout
}
export type WebNotificationLocation = 'left' | 'right'
export abstract class AbstractWebNotificationManager {
protected readonly AUTO_DISMISS_DELAY_MS = 30 * 1000
abstract getNotifications(): WebNotification[]
abstract getNotificationLocation(): WebNotificationLocation
abstract setNotificationLocation(location: WebNotificationLocation): void
protected abstract addNotificationToStorage(notification: WebNotification): void
protected abstract removeNotificationFromStorage(id: string | number): void
protected abstract removeNotificationFromStorageByIndex(index: number): void
protected abstract clearAllNotificationsFromStorage(): void
addNotification = (notification: Partial<WebNotification>): void => {
const existingNotif = this.findExistingNotification(notification)
if (existingNotif) {
this.refreshNotificationTimer(existingNotif)
existingNotif.count = (existingNotif.count || 0) + 1
return
}
const newNotification = this.createNotification(notification)
this.setNotificationTimer(newNotification)
this.addNotificationToStorage(newNotification)
}
removeNotification = (id: string | number): void => {
const notifications = this.getNotifications()
const notification = notifications.find((n) => n.id === id)
if (notification) {
this.clearNotificationTimer(notification)
this.removeNotificationFromStorage(id)
}
}
removeNotificationByIndex = (index: number): void => {
const notifications = this.getNotifications()
if (index >= 0 && index < notifications.length) {
const notification = notifications[index]
this.clearNotificationTimer(notification)
this.removeNotificationFromStorageByIndex(index)
}
}
clearAllNotifications = (): void => {
const notifications = this.getNotifications()
notifications.forEach((notification) => {
this.clearNotificationTimer(notification)
})
this.clearAllNotificationsFromStorage()
}
setNotificationTimer = (notification: WebNotification): void => {
if (!notification) return
this.clearNotificationTimer(notification)
notification.timer = setTimeout(() => {
this.removeNotification(notification.id)
}, this.AUTO_DISMISS_DELAY_MS)
}
stopNotificationTimer = (notification: WebNotification): void => {
this.clearNotificationTimer(notification)
}
private refreshNotificationTimer(notification: WebNotification): void {
this.setNotificationTimer(notification)
}
private clearNotificationTimer(notification: WebNotification): void {
if (notification.timer) {
clearTimeout(notification.timer)
notification.timer = undefined
}
}
private findExistingNotification(
notification: Partial<WebNotification>,
): WebNotification | undefined {
return this.getNotifications().find(
(existing) =>
existing.text === notification.text &&
existing.title === notification.title &&
existing.type === notification.type,
)
}
private createNotification(notification: Partial<WebNotification>): WebNotification {
return {
...notification,
id: new Date().getTime(),
count: 1,
} as WebNotification
}
}
export const [injectNotificationManager, provideNotificationManager] =
createContext<AbstractWebNotificationManager>('root', 'notificationManager')

/**
* MIT License
*
* Copyright (c) 2023 UnoVue
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* @source https://github.com/unovue/reka-ui/blob/53b4734734f8ebef9a344b1e62db291177c59bfe/packages/core/src/shared/createContext.ts
*/
import type { InjectionKey } from 'vue'
import { inject, provide } from 'vue'
/**
* @param providerComponentName - The name(s) of the component(s) providing the context.
*
* There are situations where context can come from multiple components. In such cases, you might need to give an array of component names to provide your context, instead of just a single string.
*
* @param contextName The description for injection key symbol.
*/
export function createContext<ContextValue>(
providerComponentName: string | string[],
contextName?: string,
) {
const symbolDescription =
typeof providerComponentName === 'string' && !contextName
? `${providerComponentName}Context`
: contextName
const injectionKey: InjectionKey<ContextValue | null> = Symbol(symbolDescription)
/**
* @param fallback The context value to return if the injection fails.
*
* @throws When context injection failed and no fallback is specified.
* This happens when the component injecting the context is not a child of the root component providing the context.
*/
const injectContext = <T extends ContextValue | null | undefined = ContextValue>(
fallback?: T,
): T extends null ? ContextValue | null : ContextValue => {
const context = inject(injectionKey, fallback)
if (context) return context
if (context === null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return context as any
throw new Error(
`Injection \`${injectionKey.toString()}\` not found. Component must be used within ${
Array.isArray(providerComponentName)
? `one of the following components: ${providerComponentName.join(', ')}`
: `\`${providerComponentName}\``
}`,
)
}
const provideContext = (contextValue: ContextValue) => {
provide(injectionKey, contextValue)
return contextValue
}
return [injectContext, provideContext] as const
}
export * from './web-notifications'

import { useState } from "#app";
import {
type WebNotification,
type WebNotificationLocation,
AbstractWebNotificationManager,
} from "@modrinth/ui";
export class FrontendNotificationManager extends AbstractWebNotificationManager {
private readonly state: Ref<WebNotification[]>;
private readonly locationState: Ref<WebNotificationLocation>;
public constructor() {
super();
this.state = useState<WebNotification[]>("notifications", () => []);
this.locationState = useState<WebNotificationLocation>("notifications.location", () => "right");
}
public getNotificationLocation(): WebNotificationLocation {
return this.locationState.value;
}
public setNotificationLocation(location: WebNotificationLocation): void {
this.locationState.value = location;
}
public getNotifications(): WebNotification[] {
return this.state.value;
}
protected addNotificationToStorage(notification: WebNotification): void {
this.state.value.push(notification);
}
protected removeNotificationFromStorage(id: string | number): void {
const index = this.state.value.findIndex((n) => n.id === id);
if (index > -1) {
this.state.value.splice(index, 1);
}
}
protected removeNotificationFromStorageByIndex(index: number): void {
this.state.value.splice(index, 1);
}
protected clearAllNotificationsFromStorage(): void {
this.state.value.splice(0);
}
}

import { createContext } from "@modrinth/ui";
import { type Organization, type OrganizationMember, type ProjectV3 } from "@modrinth/utils";
export class OrganizationContext {
public readonly organization: Ref<Organization | null>;
public readonly projects: Ref<ProjectV3[] | null>;
private readonly auth: Ref<any>;
private readonly tags: Ref<any>;
private readonly refreshFunction: () => Promise<void>;
public constructor(
organization: Ref<Organization | null>,
projects: Ref<ProjectV3[] | null>,
auth: Ref<any>,
tags: Ref<any>,
refreshFunction: () => Promise<void>,
) {
this.organization = organization;
this.projects = projects;
this.auth = auth;
this.tags = tags;
this.refreshFunction = refreshFunction;
}
public refresh = async () => {
if (this.organization.value === null) {
throw new Error("Organization is not set.");
}
await this.refreshFunction();
};
public currentMember = computed<Partial<OrganizationMember> | null>(() => {
if (this.auth.value.user && this.organization.value) {
const member = this.organization.value.members.find(
(x) => x.user.id === this.auth.value.user.id,
);
if (member) {
return member;
}
if (this.tags.value.staffRoles.includes(this.auth.value.user.role)) {
return {
user: this.auth.value.user,
role: this.auth.value.user.role,
permissions: this.auth.value.user.role === "admin" ? 1023 : 12,
accepted: true,
payouts_split: 0,
avatar_url: this.auth.value.user.avatar_url,
name: this.auth.value.user.username,
} as Partial<OrganizationMember>;
}
}
return null;
});
public hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2;
return (
this.currentMember.value &&
(this.currentMember.value.permissions & EDIT_DETAILS) === EDIT_DETAILS
);
});
public patchIcon = async (icon: { name: string }) => {
if (this.organization.value === null) {
throw new Error("Organization is not set.");
}
const ext = icon.name.split(".").pop();
await useBaseFetch(`organization/${this.organization.value.id}/icon`, {
method: "PATCH",
body: icon,
query: { ext },
apiVersion: 3,
});
};
public deleteIcon = async () => {
if (this.organization.value === null) {
throw new Error("Organization is not set.");
}
await useBaseFetch(`organization/${this.organization.value.id}/icon`, {
method: "DELETE",
apiVersion: 3,
});
};
public patchOrganization = async (newData: { slug: any }) => {
if (this.organization.value === null) {
throw new Error("Organization is not set.");
}
await useBaseFetch(`organization/${this.organization.value.id}`, {
method: "PATCH",
body: newData,
apiVersion: 3,
});
await this.refreshFunction();
};
}
export const [injectOrganizationContext, provideOrganizationContext] =
createContext<OrganizationContext>("[id].vue", "organizationContext");

import {
AbstractWebNotificationManager,
type WebNotification,
type WebNotificationLocation,
} from '@modrinth/ui'
import { ref, type Ref } from 'vue'
export class AppNotificationManager extends AbstractWebNotificationManager {
private readonly state: Ref<WebNotification[]>
private readonly locationState: Ref<WebNotificationLocation>
public constructor() {
super()
this.state = ref<WebNotification[]>([])
this.locationState = ref<WebNotificationLocation>('right')
}
public handleError = (error: Error): void => {
this.addNotification({
title: 'An error occurred',
text: error.message ?? error,
type: 'error',
})
}
public getNotificationLocation(): WebNotificationLocation {
return this.locationState.value
}
public setNotificationLocation(location: WebNotificationLocation): void {
this.locationState.value = location
}
public getNotifications(): WebNotification[] {
return this.state.value
}
protected addNotificationToStorage(notification: WebNotification): void {
this.state.value.push(notification)
}
protected removeNotificationFromStorage(id: string | number): void {
const index = this.state.value.findIndex((n) => n.id === id)
if (index > -1) {
this.state.value.splice(index, 1)
}
}
protected removeNotificationFromStorageByIndex(index: number): void {
this.state.value.splice(index, 1)
}
protected clearAllNotificationsFromStorage(): void {
this.state.value.splice(0)
}
}

@IMB11 IMB11 enabled auto-merge July 31, 2025 12:35
@IMB11
Copy link
Member Author

IMB11 commented Jul 31, 2025

CI is broken for app, not sure why.

@IMB11
Copy link
Member Author

IMB11 commented Aug 1, 2025

Prerequisite of #4080

@IMB11 IMB11 requested a review from Prospector August 2, 2025 11:40
@IMB11 IMB11 disabled auto-merge August 13, 2025 20:35
@IMB11 IMB11 enabled auto-merge August 13, 2025 20:40
@IMB11 IMB11 added this pull request to the merge queue Aug 13, 2025
Merged via the queue into main with commit b81e727 Aug 13, 2025
2 checks passed
@IMB11 IMB11 deleted the cal/dependency-injection branch August 15, 2025 10:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
app Relates to Modrinth App enhancement New feature or request frontend web Relates to Modrinth.com web frontend
Development

Successfully merging this pull request may close these issues.

2 participants