feat(notifications): Overlay component
This commit is contained in:
parent
b2ac76f417
commit
f2a1ae682b
8 changed files with 249 additions and 41 deletions
|
|
@ -28,8 +28,9 @@ class _Emitter {
|
|||
|
||||
const emitter = new _Emitter();
|
||||
|
||||
export async function addNotification(notification: MakeOptional<Core.Notification, 'created_at'>): Promise<void> {
|
||||
export async function addNotification(notification: MakeOptional<Core.Notification, 'id' | 'created_at'>): Promise<void> {
|
||||
_notifications.push({
|
||||
id: _notifications.length + 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
...notification,
|
||||
});
|
||||
|
|
@ -42,7 +43,15 @@ export async function addNotification(notification: MakeOptional<Core.Notificati
|
|||
// });
|
||||
}
|
||||
|
||||
export async function removeNotification(index: number): Promise<void> {
|
||||
export async function removeNotificationById(id: number): Promise<void> {
|
||||
const index = _notifications.findIndex(noti => noti.id === id);
|
||||
if (index > -1) {
|
||||
_notifications.splice(index, 1);
|
||||
emitter.emit('removed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeNotificationByIndex(index: number): Promise<void> {
|
||||
_notifications.splice(index, 1);
|
||||
emitter.emit('removed');
|
||||
// await invoke('plugin:onelauncher|notifications_remove_by_index', { index });
|
||||
|
|
|
|||
3
desktop/src/types/core.d.ts
vendored
3
desktop/src/types/core.d.ts
vendored
|
|
@ -1,5 +1,6 @@
|
|||
declare namespace Core {
|
||||
export interface Notification {
|
||||
id: number;
|
||||
created_at: number;
|
||||
title: string;
|
||||
message: string;
|
||||
|
|
@ -7,5 +8,5 @@ declare namespace Core {
|
|||
progress?: number;
|
||||
}
|
||||
|
||||
export type NotificationEvents = 'added' | 'removed' | 'cleared';
|
||||
export type NotificationEvents = 'init' | 'added' | 'removed' | 'cleared';
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import WindowFrame from './components/WindowFrame';
|
|||
import Navbar from './components/Navbar';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import AnimatedRoutes from './components/AnimatedRoutes';
|
||||
import NotificationOverlay from './components/overlay/notifications/NotificationOverlay';
|
||||
|
||||
function App(props: ParentProps) {
|
||||
if (!environment.isDev()) {
|
||||
|
|
@ -31,6 +32,8 @@ function App(props: ParentProps) {
|
|||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationOverlay />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { type JSX, Show } from 'solid-js';
|
||||
import { type JSX, Show, createEffect, createSignal } from 'solid-js';
|
||||
import { AlertTriangleIcon, CheckCircleIcon, FolderCheckIcon, FolderDownloadIcon, FolderXIcon, InfoCircleIcon, RefreshCcw02Icon } from '@untitled-theme/icons-solid';
|
||||
import TimeAgo from '../../TimeAgo';
|
||||
import { NotificationType } from '../../../../bridge/notifications';
|
||||
import { PausableTimer } from '~utils/PausableTimer';
|
||||
|
||||
interface NotificationComponentProps {
|
||||
overlay: boolean;
|
||||
|
|
@ -48,11 +49,75 @@ function ColorFromNotificationType(type: NotificationType): string {
|
|||
}
|
||||
}
|
||||
|
||||
const TOTAL_SECONDS = 7.5;
|
||||
// TODO(refactor): Use only an interval for this (Remove the pausable signal and in the interval, check if elapsed is longer than duraihon)
|
||||
function NotificationOverlayComponent(props: NotificationComponentProps) {
|
||||
const [disappearing, setDisappearing] = createSignal<boolean>(true);
|
||||
const [pausable, setPausable] = createSignal<PausableTimer | undefined>();
|
||||
const [interval, setSecondsInterval] = createSignal<PausableTimer | undefined>();
|
||||
const [visible, setVisible] = createSignal<boolean>(true);
|
||||
const [secondsLeft, setSecondsLeft] = createSignal<number>(TOTAL_SECONDS);
|
||||
|
||||
createEffect(() => {
|
||||
setDisappearing(props.data.progress === undefined || props.data.progress > 1);
|
||||
|
||||
if (disappearing()) {
|
||||
initTimers();
|
||||
}
|
||||
else if (pausable()) {
|
||||
clearTimeout(pausable()?.timeout);
|
||||
setPausable(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
function initTimers() {
|
||||
setPausable(new PausableTimer(hide, (TOTAL_SECONDS + 0.5) * 1000));
|
||||
setSecondsInterval(new PausableTimer(onInterval, 1000, true));
|
||||
}
|
||||
|
||||
function onInterval() {
|
||||
setSecondsLeft(secondsLeft() - 1);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setVisible(false);
|
||||
interval()?.stop();
|
||||
setSecondsInterval(undefined);
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
pausable()?.pause();
|
||||
interval()?.pause();
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
pausable()?.resume();
|
||||
interval()?.resume();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>{props.data.title}</span>
|
||||
</div>
|
||||
<Show when={visible()}>
|
||||
<div
|
||||
onMouseEnter={() => onEnter()}
|
||||
onMouseLeave={() => onLeave()}
|
||||
class="flex flex-col overflow-hidden rounded-lg bg-component-bg"
|
||||
>
|
||||
<div class="px-2">
|
||||
<NotificationPopupComponent {...props} />
|
||||
</div>
|
||||
|
||||
<Show when={disappearing()}>
|
||||
<div class="w-full h-1.5 bg-brand-disabled">
|
||||
<div
|
||||
style={{
|
||||
width: `${(secondsLeft() / TOTAL_SECONDS) * 100}%`,
|
||||
}}
|
||||
class="transition-width h-1.5 bg-brand rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -69,12 +134,14 @@ function NotificationPopupComponent(props: NotificationComponentProps) {
|
|||
<span class="text-sm text-white/60">{props.data.message}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-end items-center gap-1">
|
||||
<span class="text-sm text-white/40">
|
||||
<TimeAgo timestamp={props.data.created_at * 1000} />
|
||||
</span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-brand" />
|
||||
</div>
|
||||
<Show when={props.overlay !== true}>
|
||||
<div class="flex flex-row justify-end items-center gap-1">
|
||||
<span class="text-sm text-white/40">
|
||||
<TimeAgo timestamp={props.data.created_at * 1000} />
|
||||
</span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-brand" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.data.progress}>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,59 @@
|
|||
export default function NotificationOverlay() {
|
||||
import { For, createSignal } from 'solid-js';
|
||||
import Popup from '../Popup';
|
||||
import NotificationComponent from './NotificationComponent';
|
||||
import useNotifications from '~ui/hooks/useNotifications';
|
||||
import createPausableTimer from '~utils/PausableTimer';
|
||||
|
||||
export default function NotificationOverlay() {
|
||||
const [notifications] = useNotifications();
|
||||
|
||||
// function onAdded() {
|
||||
// const noti = notifications()[notifications().length - 1];
|
||||
// if (noti) {
|
||||
// displayed().push(noti);
|
||||
// setDisplayed([...displayed()]);
|
||||
// const id = noti.id;
|
||||
|
||||
// const pausable = createPausableTimer(() => {
|
||||
// if (!notifications().includes(noti))
|
||||
// return;
|
||||
|
||||
// const index = displayed().findIndex(noti => noti.id === id);
|
||||
|
||||
// if (index > -1) {
|
||||
// displayed().splice(index, 1);
|
||||
// setDisplayed([...displayed()]);
|
||||
// }
|
||||
// }, 2500);
|
||||
// }
|
||||
// }
|
||||
|
||||
// function onRemoved() {
|
||||
// const index = displayed().findIndex(noti => !notifications().includes(noti));
|
||||
// if (index > -1) {
|
||||
// displayed().splice(index, 1);
|
||||
// setDisplayed([...displayed()]);
|
||||
// }
|
||||
// }
|
||||
|
||||
// function onClear() {
|
||||
// setDisplayed([]);
|
||||
// }
|
||||
|
||||
return (
|
||||
<Popup
|
||||
setVisible={() => {}}
|
||||
visible={() => true}
|
||||
mount={document.body}
|
||||
ref={ref => ref.classList.add('fixed', 'bottom-8', 'right-8')}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={notifications()}>
|
||||
{notification => (
|
||||
<NotificationComponent overlay data={notification} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { For, Match, Switch, createEffect, createSignal, onMount } from 'solid-js';
|
||||
import { For, Match, Switch, onMount } from 'solid-js';
|
||||
import { Settings01Icon, Trash01Icon } from '@untitled-theme/icons-solid';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
|
||||
import Popup from '../Popup';
|
||||
import Button from '../../base/Button';
|
||||
import * as manager from '../../../../bridge/notifications';
|
||||
import NotificationComponent from './NotificationComponent';
|
||||
import useNotifications from '~ui/hooks/useNotifications';
|
||||
|
||||
function NotificationPopup(props: Popup.PopupProps) {
|
||||
const [notifications, setNotifications] = createSignal<Core.Notification[]>([]);
|
||||
const [notifications] = useNotifications(updateSize);
|
||||
|
||||
let inner!: HTMLDivElement;
|
||||
let parent!: HTMLDivElement;
|
||||
|
|
@ -19,12 +20,6 @@ function NotificationPopup(props: Popup.PopupProps) {
|
|||
}
|
||||
}
|
||||
|
||||
async function refetchNotifications() {
|
||||
const notis = await manager.getNotifications();
|
||||
setNotifications(notis);
|
||||
updateSize();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'n') {
|
||||
|
|
@ -32,40 +27,29 @@ function NotificationPopup(props: Popup.PopupProps) {
|
|||
title: 'Test Notification',
|
||||
message: 'This is a test notification',
|
||||
notification_type: manager.NotificationType.Download,
|
||||
progress: 0.39,
|
||||
...(Math.random() > 0.7 ? { progress: 0.39 } : {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
refetchNotifications();
|
||||
manager.on('added', refetchNotifications);
|
||||
manager.on('removed', refetchNotifications);
|
||||
manager.on('cleared', refetchNotifications);
|
||||
});
|
||||
|
||||
return (
|
||||
<Popup {...props}>
|
||||
<div class="bg-secondary rounded-xl border border-gray-10 w-96 p-2 shadow-md shadow-black/30">
|
||||
<div class="overflow-hidden transition-[height]" ref={parent}>
|
||||
<div class="overflow-hidden transition-height" ref={parent}>
|
||||
<div class="flex flex-col justify-start items-stretch text-start gap-2" ref={inner}>
|
||||
<p class="text-2lg px-2 pt-1">Notifications</p>
|
||||
|
||||
{notifications().length}
|
||||
|
||||
<Switch>
|
||||
<Match when={notifications().length > 0}>
|
||||
<OverlayScrollbarsComponent class="max-h-[min(500px,60vh)] overflow-auto">
|
||||
<div class="flex flex-col-reverse justify-center items-stretch ">
|
||||
<For each={notifications()}>
|
||||
{(noti) => {
|
||||
console.log(noti);
|
||||
return (
|
||||
<div class="flex flex-col w-full">
|
||||
<NotificationComponent data={noti} overlay={false} />
|
||||
<span class="bg-gray-05 h-px w-full" />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
{noti => (
|
||||
<div class="flex flex-col w-full">
|
||||
<NotificationComponent data={noti} overlay={false} />
|
||||
<span class="bg-gray-05 h-px w-full" />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
|
|
|||
29
desktop/src/ui/hooks/useNotifications.ts
Normal file
29
desktop/src/ui/hooks/useNotifications.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { type Signal, createSignal, onMount } from 'solid-js';
|
||||
import * as manager from '~bridge/notifications';
|
||||
|
||||
type Notifications = Core.Notification[];
|
||||
type OnUpdateFn = (event: Core.NotificationEvents) => unknown;
|
||||
|
||||
function useNotifications(onUpdate?: OnUpdateFn): Signal<Notifications> {
|
||||
const [notifications, setNotifications] = createSignal<Notifications>([]);
|
||||
|
||||
async function update(event: Core.NotificationEvents) {
|
||||
const notis = await manager.getNotifications();
|
||||
setNotifications(notis);
|
||||
|
||||
if (onUpdate)
|
||||
onUpdate(event);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
update('init');
|
||||
|
||||
manager.on('added', () => update('added'));
|
||||
manager.on('removed', () => update('removed'));
|
||||
manager.on('cleared', () => update('cleared'));
|
||||
});
|
||||
|
||||
return [notifications, setNotifications];
|
||||
}
|
||||
|
||||
export default useNotifications;
|
||||
59
desktop/src/utils/PausableTimer.ts
Normal file
59
desktop/src/utils/PausableTimer.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export class PausableTimer {
|
||||
private _timeout: NodeJS.Timeout;
|
||||
private _paused: boolean = false;
|
||||
private _unixStarted: number = 0;
|
||||
private _unixStopped: number = 0;
|
||||
|
||||
constructor(
|
||||
private readonly callback: () => unknown,
|
||||
private readonly duration: number,
|
||||
private readonly interval: boolean = false,
|
||||
) {
|
||||
this._timeout = this._createTimeout();
|
||||
}
|
||||
|
||||
private _createTimeout(duration: number = this.duration): NodeJS.Timeout {
|
||||
this._unixStarted = Date.now();
|
||||
if (this.interval)
|
||||
return setInterval(this.callback, duration);
|
||||
else
|
||||
return setTimeout(this.callback, duration);
|
||||
}
|
||||
|
||||
private _clearTimeout() {
|
||||
this._unixStopped = Date.now();
|
||||
if (this.interval)
|
||||
clearInterval(this._timeout);
|
||||
else
|
||||
clearTimeout(this._timeout);
|
||||
}
|
||||
|
||||
public resume(restart: boolean = false) {
|
||||
const elapsed = this._unixStopped - this._unixStarted;
|
||||
const duration = this.interval || restart ? this.duration : this.duration - elapsed;
|
||||
|
||||
this._paused = false;
|
||||
this._timeout = this._createTimeout(duration);
|
||||
}
|
||||
|
||||
public pause() {
|
||||
this._clearTimeout();
|
||||
this._paused = true;
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this._clearTimeout();
|
||||
}
|
||||
|
||||
public get timeout() {
|
||||
return this._timeout;
|
||||
}
|
||||
|
||||
public get paused() {
|
||||
return this._paused;
|
||||
}
|
||||
}
|
||||
|
||||
export default function createPausableTimer(callback: () => unknown, duration: number): PausableTimer {
|
||||
return new PausableTimer(callback, duration);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue