Skip to content

Commit 55acf43

Browse files
authored
Merge pull request #6795 from remix-project-org/notifications
notifications
2 parents 86007d0 + 3942a99 commit 55acf43

File tree

11 files changed

+803
-0
lines changed

11 files changed

+803
-0
lines changed

apps/remix-ide/src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import { DesktopHost } from './app/plugins/electron/desktopHostPlugin'
8383
import { WalletConnect } from './app/plugins/walletconnect'
8484
import { AIDappGenerator } from './app/plugins/ai-dapp-generator'
8585
import { IndexedDbCachePlugin } from './app/plugins/IndexedDbCache'
86+
import { NotificationCenterPlugin } from './app/plugins/notification-center'
8687
import { FeedbackPlugin } from './app/plugins/feedback'
8788

8889
import { TemplatesSelectionPlugin } from './app/plugins/templates-selection/templates-selection-plugin'
@@ -433,6 +434,7 @@ class AppComponent {
433434
const solidityScript = new SolidityScript()
434435

435436
this.notification = new NotificationPlugin()
437+
const notificationCenter = new NotificationCenterPlugin()
436438

437439
const configPlugin = new ConfigPlugin()
438440
this.layout = new Layout()
@@ -451,6 +453,7 @@ class AppComponent {
451453
permissionHandler,
452454
this.layout,
453455
this.notification,
456+
notificationCenter,
454457
this.gistHandler,
455458
configPlugin,
456459
blockchain,
@@ -704,6 +707,7 @@ class AppComponent {
704707
await this.appManager.activatePlugin(['auth'])
705708
await this.appManager.activatePlugin(['invitationManager'])
706709
await this.appManager.activatePlugin(['account'])
710+
await this.appManager.activatePlugin(['notificationCenter'])
707711
await this.appManager.activatePlugin(['feedback'])
708712
await this.appManager.activatePlugin(['settings'])
709713

apps/remix-ide/src/app/plugins/auth-plugin.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,16 @@ export class AuthPlugin extends Plugin {
548548
console.log('[AuthPlugin] Access token refreshed successfully')
549549
// Reschedule next proactive refresh
550550
this.scheduleRefresh(newAccessToken)
551+
552+
// Notify other plugins about the refreshed token
553+
const userStr = localStorage.getItem('remix_user')
554+
const user = userStr ? JSON.parse(userStr) : null
555+
this.emit('authStateChanged', {
556+
isAuthenticated: true,
557+
user,
558+
token: newAccessToken
559+
})
560+
551561
return newAccessToken
552562
}
553563

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { Plugin } from '@remixproject/engine'
2+
import { CustomRemixApi, NotificationItem, NotificationsResponse, UnreadCountResponse, MarkReadResponse, MarkAllReadResponse } from '@remix-api'
3+
import { ApiClient, IApiClient } from '@remix-api'
4+
import { endpointUrls } from '@remix-endpoints-helper'
5+
6+
const profile = {
7+
name: 'notificationCenter',
8+
displayName: 'Notification Center',
9+
description: 'In-app notification center for Remix IDE',
10+
methods: ['getNotifications', 'getUnreadCount', 'markAsRead', 'dismiss', 'markAllAsRead', 'startPolling', 'stopPolling'],
11+
events: ['unreadCountChanged', 'notificationsUpdated'],
12+
version: '0.0.1'
13+
}
14+
15+
const POLL_INTERVAL = 30000 // 30 seconds
16+
17+
export class NotificationCenterPlugin extends Plugin<any, CustomRemixApi> {
18+
private apiClient: IApiClient
19+
private pollTimer: ReturnType<typeof setInterval> | null = null
20+
private lastUnreadCount = 0
21+
private cachedNotifications: NotificationItem[] = []
22+
private isAuthenticated = false
23+
24+
constructor() {
25+
super(profile)
26+
this.apiClient = new ApiClient(endpointUrls.notifications)
27+
}
28+
29+
async onActivation(): Promise<void> {
30+
// Listen for auth state changes (login, logout, AND token refresh)
31+
this.on('auth' as any, 'authStateChanged', async (state: { isAuthenticated: boolean; token?: string }) => {
32+
this.isAuthenticated = state.isAuthenticated
33+
if (state.isAuthenticated) {
34+
// Use the token from the event if available, otherwise read from localStorage
35+
const token = state.token || localStorage.getItem('remix_access_token')
36+
if (token) {
37+
this.apiClient.setToken(token)
38+
}
39+
// Only start polling if not already running (avoid restart on token refresh)
40+
if (!this.pollTimer) {
41+
await this.startPolling()
42+
}
43+
} else {
44+
this.stopPolling()
45+
this.lastUnreadCount = 0
46+
this.cachedNotifications = []
47+
this.apiClient.setToken(null)
48+
this.emit('unreadCountChanged', 0)
49+
this.emit('notificationsUpdated', [])
50+
}
51+
})
52+
53+
// Check initial auth state
54+
const token = localStorage.getItem('remix_access_token')
55+
if (token) {
56+
this.isAuthenticated = true
57+
this.apiClient.setToken(token)
58+
this.setupTokenRefresh()
59+
await this.startPolling()
60+
}
61+
}
62+
63+
/**
64+
* Configure the ApiClient to handle 401s by triggering a real token refresh
65+
* through the auth plugin, not just reading (possibly stale) localStorage.
66+
*/
67+
private setupTokenRefresh(): void {
68+
this.apiClient.setTokenRefreshCallback(async () => {
69+
try {
70+
// Ask the auth plugin to refresh — this triggers a real refresh-token exchange
71+
// and emits authStateChanged with the new token, which we also listen to above.
72+
const newToken = await this.call('auth' as any, 'getToken')
73+
return newToken
74+
} catch {
75+
return null
76+
}
77+
})
78+
}
79+
80+
onDeactivation(): void {
81+
this.stopPolling()
82+
}
83+
84+
async getNotifications(limit = 20, offset = 0, unreadOnly = false): Promise<NotificationsResponse> {
85+
if (!this.isAuthenticated) {
86+
return { notifications: [], total: 0, unread: 0 }
87+
}
88+
89+
try {
90+
const params = new URLSearchParams({
91+
limit: String(limit),
92+
offset: String(offset),
93+
...(unreadOnly ? { unread_only: 'true' } : {}),
94+
include_dismissed: 'false'
95+
})
96+
const response = await this.apiClient.get<NotificationsResponse>(`?${params.toString()}`)
97+
98+
if (response.ok && response.data) {
99+
this.cachedNotifications = response.data.notifications
100+
this.emit('notificationsUpdated', this.cachedNotifications)
101+
102+
if (response.data.unread !== this.lastUnreadCount) {
103+
this.lastUnreadCount = response.data.unread
104+
this.emit('unreadCountChanged', this.lastUnreadCount)
105+
}
106+
107+
return response.data
108+
}
109+
} catch (e) {
110+
console.error('[NotificationCenter] Failed to fetch notifications:', e)
111+
}
112+
113+
return { notifications: this.cachedNotifications, total: this.cachedNotifications.length, unread: this.lastUnreadCount }
114+
}
115+
116+
async getUnreadCount(): Promise<number> {
117+
if (!this.isAuthenticated) return 0
118+
119+
try {
120+
const response = await this.apiClient.get<UnreadCountResponse>('/unread-count')
121+
if (response.ok && response.data) {
122+
const newCount = response.data.unread
123+
if (newCount !== this.lastUnreadCount) {
124+
this.lastUnreadCount = newCount
125+
this.emit('unreadCountChanged', this.lastUnreadCount)
126+
}
127+
return newCount
128+
}
129+
} catch (e) {
130+
console.error('[NotificationCenter] Failed to fetch unread count:', e)
131+
}
132+
133+
return this.lastUnreadCount
134+
}
135+
136+
async markAsRead(id: number): Promise<void> {
137+
if (!this.isAuthenticated) return
138+
139+
try {
140+
const response = await this.apiClient.post<MarkReadResponse>(`/${id}/read`)
141+
if (response.ok) {
142+
// Update cached notification
143+
const notification = this.cachedNotifications.find(n => n.id === id)
144+
if (notification && notification.read_status !== 'read') {
145+
notification.read_status = 'read'
146+
notification.read_at = new Date().toISOString()
147+
this.lastUnreadCount = Math.max(0, this.lastUnreadCount - 1)
148+
this.emit('unreadCountChanged', this.lastUnreadCount)
149+
this.emit('notificationsUpdated', [...this.cachedNotifications])
150+
}
151+
}
152+
} catch (e) {
153+
console.error('[NotificationCenter] Failed to mark as read:', e)
154+
}
155+
}
156+
157+
async dismiss(id: number): Promise<void> {
158+
if (!this.isAuthenticated) return
159+
160+
try {
161+
const response = await this.apiClient.post<MarkReadResponse>(`/${id}/dismiss`)
162+
if (response.ok) {
163+
const wasPreviouslyUnread = this.cachedNotifications.find(n => n.id === id)?.read_status === null
164+
this.cachedNotifications = this.cachedNotifications.filter(n => n.id !== id)
165+
if (wasPreviouslyUnread) {
166+
this.lastUnreadCount = Math.max(0, this.lastUnreadCount - 1)
167+
this.emit('unreadCountChanged', this.lastUnreadCount)
168+
}
169+
this.emit('notificationsUpdated', [...this.cachedNotifications])
170+
}
171+
} catch (e) {
172+
console.error('[NotificationCenter] Failed to dismiss notification:', e)
173+
}
174+
}
175+
176+
async markAllAsRead(): Promise<void> {
177+
if (!this.isAuthenticated) return
178+
179+
try {
180+
const response = await this.apiClient.post<MarkAllReadResponse>('/read-all')
181+
if (response.ok) {
182+
this.cachedNotifications = this.cachedNotifications.map(n => ({
183+
...n,
184+
read_status: n.read_status === 'dismissed' ? 'dismissed' : 'read' as const,
185+
read_at: n.read_at || new Date().toISOString()
186+
}))
187+
this.lastUnreadCount = 0
188+
this.emit('unreadCountChanged', 0)
189+
this.emit('notificationsUpdated', [...this.cachedNotifications])
190+
}
191+
} catch (e) {
192+
console.error('[NotificationCenter] Failed to mark all as read:', e)
193+
}
194+
}
195+
196+
/**
197+
* Sync the ApiClient token from localStorage as a safety net.
198+
* Covers edge cases where the authStateChanged event was missed.
199+
*/
200+
private syncToken(): void {
201+
const token = localStorage.getItem('remix_access_token')
202+
if (token) {
203+
this.apiClient.setToken(token)
204+
}
205+
}
206+
207+
async startPolling(): Promise<void> {
208+
this.stopPolling()
209+
// Fetch immediately
210+
this.syncToken()
211+
await this.getUnreadCount()
212+
// Then poll — sync token each cycle in case it was refreshed between polls
213+
this.pollTimer = setInterval(async () => {
214+
this.syncToken()
215+
await this.getUnreadCount()
216+
}, POLL_INTERVAL)
217+
}
218+
219+
stopPolling(): void {
220+
if (this.pollTimer) {
221+
clearInterval(this.pollTimer)
222+
this.pollTimer = null
223+
}
224+
}
225+
}

apps/remix-ide/src/remixAppManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ let requiredModules = [
107107
'chartjs',
108108
'storageMonitor',
109109
'indexedDbCache',
110+
'notificationCenter',
110111
'invitationManager',
111112
'feedback'
112113
]

libs/endpoints-helper/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type EndpointUrls = {
2020
credits: string;
2121
audio;
2222
permissions: string;
23+
notifications: string;
2324
invite: string;
2425
feedback: string;
2526
};
@@ -46,6 +47,7 @@ const defaultUrls: EndpointUrls = {
4647
credits: 'https://auth.api.remix.live:8443/credits',
4748
audio: 'https://audio.api.remix.live',
4849
permissions: 'https://auth.api.remix.live:8443/permissions',
50+
notifications: 'https://auth.api.remix.live:8443/notifications',
4951
invite: 'https://auth.api.remix.live:8443/invite',
5052
feedback: 'https://auth.api.remix.live:8443/feedback',
5153
};
@@ -72,6 +74,7 @@ const endpointPathMap: Record<keyof EndpointUrls, string> = {
7274
credits: 'credits',
7375
audio: 'audio',
7476
permissions: 'permissions',
77+
notifications: 'notifications',
7578
invite: 'invite',
7679
feedback: 'feedback',
7780
};
@@ -117,6 +120,8 @@ const localhostUrls: EndpointUrls = {
117120
// PERMISSIONS service
118121
permissions: 'https://auth.api.remix.live:8443/permissions',
119122

123+
// NOTIFICATIONS service (port 3013)
124+
notifications: 'http://localhost:3013/notifications',
120125
// INVITE service
121126
invite: 'https://auth.api.remix.live:8443/invite',
122127

libs/remix-api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ export * from './lib/plugins/sso-api'
88
export * from './lib/plugins/api-client'
99
export * from './lib/plugins/api-types'
1010
export * from './lib/plugins/api-services'
11+
export * from './lib/plugins/notification-center-api'
1112
export * from './lib/plugins/transaction-simulator-api'
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { StatusEvents } from '@remixproject/plugin-utils'
2+
3+
// ==================== Notification Types ====================
4+
5+
export type NotificationType = 'info' | 'warning' | 'success' | 'error' | 'announcement' | 'update'
6+
export type NotificationPriority = 'low' | 'normal' | 'high' | 'critical'
7+
export type NotificationReadStatus = 'read' | 'dismissed' | null
8+
9+
export interface NotificationItem {
10+
id: number
11+
title: string
12+
body: string
13+
type: NotificationType
14+
priority: NotificationPriority
15+
action_url: string | null
16+
action_label: string | null
17+
read_status: NotificationReadStatus
18+
read_at: string | null
19+
created_at: string
20+
expires_at: string | null
21+
}
22+
23+
export interface NotificationsResponse {
24+
notifications: NotificationItem[]
25+
total: number
26+
unread: number
27+
}
28+
29+
export interface UnreadCountResponse {
30+
unread: number
31+
}
32+
33+
export interface MarkReadResponse {
34+
success: boolean
35+
}
36+
37+
export interface MarkAllReadResponse {
38+
success: boolean
39+
marked: number
40+
}
41+
42+
// ==================== Plugin API Interface ====================
43+
44+
export interface INotificationCenterApi {
45+
events: {
46+
unreadCountChanged: (count: number) => void
47+
notificationsUpdated: (notifications: NotificationItem[]) => void
48+
} & StatusEvents
49+
methods: {
50+
getNotifications(limit?: number, offset?: number, unreadOnly?: boolean): Promise<NotificationsResponse>
51+
getUnreadCount(): Promise<number>
52+
markAsRead(id: number): Promise<void>
53+
dismiss(id: number): Promise<void>
54+
markAllAsRead(): Promise<void>
55+
startPolling(): Promise<void>
56+
stopPolling(): Promise<void>
57+
}
58+
}

libs/remix-api/src/lib/remix-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { IDesktopClient } from "./plugins/desktop-client"
2121
import { IGitHubAuthHandlerApi } from "./plugins/githubAuthHandler-api"
2222
import { ITopbarApi } from "./plugins/topbar-api"
2323
import { ISSOApi } from "./plugins/sso-api"
24+
import { INotificationCenterApi } from "./plugins/notification-center-api"
2425

2526
export interface ICustomRemixApi extends IRemixApi {
2627
popupPanel: IPopupPanelAPI
@@ -45,6 +46,7 @@ export interface ICustomRemixApi extends IRemixApi {
4546
desktopClient: IDesktopClient
4647
githubAuthHandler: IGitHubAuthHandlerApi
4748
sso: ISSOApi
49+
notificationCenter: INotificationCenterApi
4850
}
4951

5052
export declare type CustomRemixApi = Readonly<ICustomRemixApi>

0 commit comments

Comments
 (0)