Skip to content

Commit b45266a

Browse files
author
ci-bot
committed
action notifications
1 parent 04b1ba9 commit b45266a

File tree

13 files changed

+255
-13
lines changed

13 files changed

+255
-13
lines changed

apps/remix-ide/src/app/plugins/notification.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Plugin } from '@remixproject/engine'
33
import { LibraryProfile, MethodApi, StatusEvents } from '@remixproject/plugin-utils'
44
import { AppModal } from '@remix-ui/app'
55
import { AlertModal } from '@remix-ui/app'
6+
import { ActionNotification } from '@remix-ui/app'
67
import { dispatchModalInterface } from '@remix-ui/app'
78
import { Toaster, toast } from '@remix-ui/toaster'
89

@@ -13,14 +14,16 @@ interface INotificationApi {
1314
alert: (args: AlertModal) => void
1415
toast: (message: string) => number
1516
hideToaster: (id: number) => void
17+
actionNotification: (args: ActionNotification) => void
18+
hideActionNotification: (id: string) => void
1619
}
1720
}
1821

1922
const profile: LibraryProfile<INotificationApi> = {
2023
name: 'notification',
2124
displayName: 'Notification',
2225
description: 'Displays notifications',
23-
methods: ['modal', 'alert', 'toast', 'hideToaster']
26+
methods: ['modal', 'alert', 'toast', 'hideToaster', 'actionNotification', 'hideActionNotification']
2427
}
2528

2629
export class NotificationPlugin extends Plugin implements MethodApi<INotificationApi> {
@@ -53,4 +56,13 @@ export class NotificationPlugin extends Plugin implements MethodApi<INotificatio
5356
async hideToaster(id: number) {
5457
toast.dismiss('toast-' + id)
5558
}
59+
60+
async actionNotification(data: ActionNotification) {
61+
const id = data.id || `action-notif-${Date.now()}-${++this.toastId}`
62+
this.dispatcher.actionNotification({ ...data, id })
63+
}
64+
65+
async hideActionNotification(id: string) {
66+
this.dispatcher.hideActionNotification(id)
67+
}
5668
}

libs/remix-ui/app/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export { default as RemixApp } from './lib/remix-app/remix-app'
22
export { dispatchModalContext, dispatchModalInterface, AppContext, appProviderContextType, appPlatformTypes, platformContext, onLineContext } from './lib/remix-app/context/context'
33
export { ModalProvider, useDialogDispatchers } from './lib/remix-app/context/provider'
44
export { AppModal } from './lib/remix-app/interface/index'
5-
export { AlertModal, AppState } from './lib/remix-app/interface/index'
5+
export { AlertModal, AppState, ActionNotification, ActionNotificationAction } from './lib/remix-app/interface/index'
66
export { ModalTypes, AppModalCancelTypes } from './lib/remix-app/types/index'
77
export { AppAction, appActionTypes } from './lib/remix-app/actions/app'
88
export { AuthProvider, useAuth, Credits, AuthState } from './lib/remix-app/context/auth-context'

libs/remix-ui/app/src/lib/remix-app/actions/modals.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AppModal, GenericModal } from '../interface'
1+
import { AppModal, GenericModal, ActionNotification } from '../interface'
22

33
type ActionMap<M extends { [index: string]: any }> = {
44
[Key in keyof M]: M[Key] extends undefined
@@ -17,7 +17,9 @@ export const enum modalActionTypes {
1717
processQueue = 'PROCESS_QUEUEU',
1818
handleHideModal = 'HANDLE_HIDE_MODAL',
1919
handleToaster = 'HANDLE_HIDE_TOAST',
20-
setTemplateExplorer = 'SET_TEMPLATE_EXPLORER'
20+
setTemplateExplorer = 'SET_TEMPLATE_EXPLORER',
21+
setActionNotification = 'SET_ACTION_NOTIFICATION',
22+
hideActionNotification = 'HIDE_ACTION_NOTIFICATION'
2123
}
2224

2325
type ModalPayload = {
@@ -26,7 +28,9 @@ type ModalPayload = {
2628
[modalActionTypes.setToast]: { message: string | JSX.Element, timestamp: number, timeout?: number }
2729
[modalActionTypes.handleToaster]: any,
2830
[modalActionTypes.processQueue]: any,
29-
[modalActionTypes.setTemplateExplorer]: GenericModal
31+
[modalActionTypes.setTemplateExplorer]: GenericModal,
32+
[modalActionTypes.setActionNotification]: ActionNotification,
33+
[modalActionTypes.hideActionNotification]: { id: string }
3034
}
3135

3236
export type ModalAction = ActionMap<ModalPayload>[keyof ActionMap<
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React, { useEffect, useRef, useCallback } from 'react'
2+
import { ActionNotification, ActionNotificationAction } from '../../interface'
3+
import { useDialogDispatchers } from '../../context/provider'
4+
5+
interface ActionNotificationBarProps {
6+
notification: ActionNotification
7+
onActionClick: (action: ActionNotificationAction, notificationId: string) => void
8+
}
9+
10+
/**
11+
* A single non-intrusive notification bar (VS Code-style).
12+
* Sits at the bottom-right, shows a message with action buttons.
13+
*/
14+
const ActionNotificationBar = ({ notification, onActionClick }: ActionNotificationBarProps) => {
15+
const { hideActionNotification } = useDialogDispatchers()
16+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
17+
18+
useEffect(() => {
19+
if (notification.timeout && notification.timeout > 0) {
20+
timerRef.current = setTimeout(() => {
21+
hideActionNotification(notification.id)
22+
}, notification.timeout)
23+
}
24+
return () => {
25+
if (timerRef.current) clearTimeout(timerRef.current)
26+
}
27+
}, [notification.id, notification.timeout])
28+
29+
const handleDismiss = useCallback(() => {
30+
hideActionNotification(notification.id)
31+
}, [notification.id])
32+
33+
const handleAction = useCallback((action: ActionNotificationAction) => {
34+
onActionClick(action, notification.id)
35+
hideActionNotification(notification.id)
36+
}, [notification.id, onActionClick])
37+
38+
return (
39+
<div className="action-notification-bar d-flex flex-column border rounded shadow-sm p-3 mb-2 bg-light"
40+
style={{
41+
minWidth: '360px',
42+
maxWidth: '480px',
43+
animation: 'slideInUp 0.25s ease-out'
44+
}}
45+
>
46+
<div className="d-flex justify-content-between align-items-start mb-1">
47+
<span className="fw-bold small text-dark">{notification.title}</span>
48+
<button
49+
type="button"
50+
className="btn-close ms-2"
51+
aria-label="Close"
52+
style={{ fontSize: '0.65rem' }}
53+
onClick={handleDismiss}
54+
/>
55+
</div>
56+
<div className="small text-body mb-2" style={{ lineHeight: '1.4' }}>
57+
{notification.message}
58+
</div>
59+
{notification.actions && notification.actions.length > 0 && (
60+
<div className="d-flex flex-wrap gap-1 justify-content-end">
61+
{notification.actions.map((action, idx) => (
62+
<button
63+
key={idx}
64+
className={`btn btn-sm btn-${action.variant || 'primary'}`}
65+
onClick={() => handleAction(action)}
66+
>
67+
{action.label}
68+
</button>
69+
))}
70+
</div>
71+
)}
72+
</div>
73+
)
74+
}
75+
76+
export default ActionNotificationBar
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React, { useContext, useCallback } from 'react'
2+
import { ActionNotificationAction } from '../../interface'
3+
import { useDialogs } from '../../context/provider'
4+
import { AppContext } from '../../context/context'
5+
import ActionNotificationBar from './action-notification-bar'
6+
7+
/**
8+
* Container that renders action notifications at the bottom-right of the viewport.
9+
* These are non-intrusive, VS Code-style suggestion bars that don't block the UI.
10+
* Actions can trigger plugin calls or direct callbacks.
11+
*/
12+
const ActionNotificationContainer = () => {
13+
const { actionNotifications } = useDialogs()
14+
const app = useContext(AppContext)
15+
16+
const handleActionClick = useCallback(async (action: ActionNotificationAction, notificationId: string) => {
17+
// If there's a direct function callback, call it
18+
if (action.fn) {
19+
action.fn()
20+
return
21+
}
22+
// Otherwise, make a plugin call
23+
if (action.plugin && action.method && app?.appManager) {
24+
try {
25+
await app.appManager.call(action.plugin, action.method, ...(action.args || []))
26+
} catch (err) {
27+
console.error(`ActionNotification: failed to call ${action.plugin}.${action.method}`, err)
28+
}
29+
}
30+
}, [app])
31+
32+
if (!actionNotifications || actionNotifications.length === 0) return null
33+
34+
return (
35+
<div
36+
className="action-notification-container"
37+
style={{
38+
position: 'fixed',
39+
bottom: '30px',
40+
right: '16px',
41+
zIndex: 10000,
42+
display: 'flex',
43+
flexDirection: 'column-reverse',
44+
pointerEvents: 'auto',
45+
maxHeight: '60vh',
46+
overflowY: 'auto'
47+
}}
48+
>
49+
{actionNotifications.map((notification) => (
50+
<ActionNotificationBar
51+
key={notification.id}
52+
notification={notification}
53+
onActionClick={handleActionClick}
54+
/>
55+
))}
56+
</div>
57+
)
58+
}
59+
60+
export default ActionNotificationContainer

libs/remix-ui/app/src/lib/remix-app/components/modals/dialogViewPlugin.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import { AppContext } from '../../context/context'
33
import { useDialogDispatchers } from '../../context/provider'
44

55
const DialogViewPlugin = () => {
6-
const { modal, alert, toast } = useDialogDispatchers()
6+
const { modal, alert, toast, actionNotification, hideActionNotification } = useDialogDispatchers()
77
const app = useContext(AppContext)
88

99
useEffect(() => {
10-
app.modal.setDispatcher({ modal, alert, toast })
10+
app.modal.setDispatcher({ modal, alert, toast, actionNotification, hideActionNotification })
1111
}, [])
1212
return <></>
1313
}

libs/remix-ui/app/src/lib/remix-app/components/modals/dialogs.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react'
22
import { useDialogDispatchers, useDialogs } from '../../context/provider'
33
import { ToasterContainer } from '@remix-ui/toaster'
44
import ModalWrapper from './modal-wrapper'
5+
import ActionNotificationContainer from './action-notification-container'
56

67
const AppDialogs = () => {
78
const { handleHideModal, handleToaster } = useDialogDispatchers()
@@ -22,6 +23,7 @@ const AppDialogs = () => {
2223
<>
2324
<ModalWrapper {...focusModal} handleHide={handleHideModal}></ModalWrapper>
2425
<ToasterContainer toasts={toastList} />
26+
<ActionNotificationContainer />
2527
</>
2628
)
2729
}

libs/remix-ui/app/src/lib/remix-app/context/context.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react'
2-
import { AlertModal, AppModal, AppState } from '../interface'
2+
import { AlertModal, AppModal, AppState, ActionNotification } from '../interface'
33
import { ModalInitialState } from '../state/modals'
44
import { AppAction } from '../actions/app'
55

@@ -30,14 +30,18 @@ export interface dispatchModalInterface {
3030
alert: (data: AlertModal) => void
3131
handleHideModal: () => void
3232
handleToaster: () => void
33+
actionNotification: (data: ActionNotification) => void
34+
hideActionNotification: (id: string) => void
3335
}
3436

3537
export const dispatchModalContext = React.createContext<dispatchModalInterface>({
3638
modal: (data: AppModal) => {},
3739
toast: (message: string | JSX.Element, timeout?: number, toastId?: number) => {},
3840
alert: (data: AlertModal) => {},
3941
handleHideModal: () => {},
40-
handleToaster: () => {}
42+
handleToaster: () => {},
43+
actionNotification: (data: ActionNotification) => {},
44+
hideActionNotification: (id: string) => {}
4145
})
4246

4347
export const modalContext = React.createContext(ModalInitialState)

libs/remix-ui/app/src/lib/remix-app/context/provider.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useReducer } from 'react'
22
import { useIntl, IntlShape } from 'react-intl'
33
import { modalActionTypes } from '../actions/modals'
4-
import { AlertModal, AppModal } from '../interface'
4+
import { AlertModal, AppModal, ActionNotification } from '../interface'
55
import { modalReducer } from '../reducer/modals'
66
import { ModalInitialState } from '../state/modals'
77
import { ModalTypes } from '../types'
@@ -13,7 +13,7 @@ declare global {
1313
}
1414

1515
export const ModalProvider = ({ children = [], reducer = modalReducer, initialState = ModalInitialState } = {}) => {
16-
const [{ modals, toasters, focusModal, focusToaster, focusTemplateExplorer }, dispatch] = useReducer(reducer, initialState)
16+
const [{ modals, toasters, focusModal, focusToaster, focusTemplateExplorer, actionNotifications }, dispatch] = useReducer(reducer, initialState)
1717

1818
const onNextFn = async () => {
1919
dispatch({
@@ -84,9 +84,23 @@ export const ModalProvider = ({ children = [], reducer = modalReducer, initialSt
8484
})
8585
}
8686

87+
const actionNotification = (data: ActionNotification) => {
88+
dispatch({
89+
type: modalActionTypes.setActionNotification,
90+
payload: data
91+
})
92+
}
93+
94+
const hideActionNotification = (id: string) => {
95+
dispatch({
96+
type: modalActionTypes.hideActionNotification,
97+
payload: { id }
98+
})
99+
}
100+
87101
return (
88-
<dispatchModalContext.Provider value={{ modal, toast, alert, handleHideModal, handleToaster }}>
89-
<modalContext.Provider value={{ modals, toasters, focusModal, focusToaster, focusTemplateExplorer }}>
102+
<dispatchModalContext.Provider value={{ modal, toast, alert, handleHideModal, handleToaster, actionNotification, hideActionNotification }}>
103+
<modalContext.Provider value={{ modals, toasters, focusModal, focusToaster, focusTemplateExplorer, actionNotifications }}>
90104
{children}
91105
</modalContext.Provider>
92106
</dispatchModalContext.Provider>

libs/remix-ui/app/src/lib/remix-app/interface/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,36 @@ export interface AlertModal {
3838
message: string | JSX.Element,
3939
}
4040

41+
export interface ActionNotificationAction {
42+
label: string
43+
/** The plugin name to call */
44+
plugin?: string
45+
/** The method on the plugin to call */
46+
method?: string
47+
/** Arguments to pass to the plugin method */
48+
args?: any[]
49+
/** Direct callback (used internally when called from React side) */
50+
fn?: () => void
51+
/** Bootstrap color variant: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'link' */
52+
variant?: string
53+
}
54+
55+
export interface ActionNotification {
56+
id: string
57+
title: string
58+
message: string | JSX.Element
59+
/** Action buttons to display */
60+
actions: ActionNotificationAction[]
61+
/** Auto-dismiss timeout in ms (0 = no auto-dismiss). Default: 0 */
62+
timeout?: number
63+
timestamp?: number
64+
hide?: boolean
65+
}
66+
4167
export interface ModalState {
4268
modals: AppModal[],
4369
toasters: {message: (string | JSX.Element), timestamp: number, timeout?: number, toastId?: number }[],
70+
actionNotifications: ActionNotification[],
4471
focusModal: AppModal,
4572
focusToaster: {message: (string | JSX.Element), timestamp: number, timeout?: number, toastId?: number }
4673
focusTemplateExplorer: GenericModal

0 commit comments

Comments
 (0)