Skip to content
This repository was archived by the owner on Apr 10, 2023. It is now read-only.

Commit

Permalink
Update Available Notifications (#65)
Browse files Browse the repository at this point in the history
* fix: clear session on logout

* chore: session redo wip

* chore: base64 encode session val, only store data we need

* feat: wip notifications

* feat: allow for dimissing notification/hiding pulse

* chore: only render if update available
  • Loading branch information
markphelps authored Feb 2, 2023
1 parent 6ec4fc3 commit 8ec6676
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 30 deletions.
42 changes: 22 additions & 20 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Bars3BottomLeftIcon } from '@heroicons/react/24/outline';
import { useEffect, useState } from 'react';
import { getInfo } from '~/data/api';
import { useSession } from '~/data/hooks/session';
import { Info } from '~/types/Meta';
import Notifications from './Notifications';
import UserProfile from './UserProfile';

type HeaderProps = {
Expand All @@ -8,7 +12,18 @@ type HeaderProps = {

export default function Header(props: HeaderProps) {
const { setSidebarOpen } = props;
// const [notifications, setNotifications] = useState(false);
const [info, setInfo] = useState<Info | null>(null);

useEffect(() => {
getInfo()
.then((info: Info) => {
setInfo(info);
})
.catch(() => {
// nothing to do, component will degrade gracefully
});
}, []);

const { session } = useSession();

return (
Expand All @@ -24,26 +39,13 @@ export default function Header(props: HeaderProps) {
<div className="flex flex-1 justify-between px-4">
<div className="flex flex-1" />
<div className="ml-4 flex items-center md:ml-6">
{/* TODO: Add back notifications when we support them */}
{/* <button
type="button"
className="without-ring relative rounded-full p-1 text-white"
> */}
{/* TODO: Add a pulse animation to this button when there are new notifications */}
{/* {notifications && (
<span className="absolute top-0 right-0 flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-violet-300"></span>
</span>
)}
<span className="sr-only">View notifications</span>
<BellIcon
className="h-8 w-6 hover:fill-violet-300"
aria-hidden="true"
/> */}
{/* </button> */}
{/* notifications */}

{/* TODO: currently we only show the update available notification,
this will need to be re-worked if we support other notifications */}
{info && info.updateAvailable && <Notifications info={info} />}

{/* Profile dropdown */}
{/* user profile */}
{session && session.self && (
<UserProfile
name={session.self.metadata['io.flipt.auth.oidc.name']}
Expand Down
142 changes: 142 additions & 0 deletions src/components/Notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Transition } from '@headlessui/react';
import {
BellIcon,
MegaphoneIcon,
XMarkIcon
} from '@heroicons/react/24/outline';
import { Fragment, useState } from 'react';
import { useSessionStorage } from '~/data/hooks/storage';
import { Info } from '~/types/Meta';

type NotificationProps = {
show: boolean;
setShow: (show: boolean) => void;
markSeen: () => void;
info: Info;
};

export function Notification(props: NotificationProps) {
const { info, show, setShow, markSeen } = props;

return (
<>
<div
aria-live="assertive"
className="pointer-events-none fixed inset-0 z-10 flex items-end px-4 py-6 sm:items-start sm:p-4"
>
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
<Transition
show={show}
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<MegaphoneIcon
className="h-6 w-6 text-gray-400"
aria-hidden="true"
/>
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-sm font-medium text-gray-900">
Update Available
</p>
<p className="mt-1 text-sm text-gray-500">
A new version of Flipt is available!
</p>
<div className="mt-3 flex space-x-7 hover:cursor-pointer">
<a
href={info.latestVersionURL}
target="_blank"
rel="noreferrer"
className="rounded-md bg-white text-sm font-medium text-violet-600 hover:text-violet-500 focus:outline-none"
>
Check It Out
</a>
<a
className="rounded-md bg-white text-sm font-medium text-gray-700 hover:text-gray-500 focus:outline-none"
onClick={(e) => {
e.preventDefault();
setShow(false);
markSeen();
}}
>
Dismiss
</a>
</div>
</div>
<div className="ml-4 flex flex-shrink-0">
<button
type="button"
className="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none"
onClick={() => {
setShow(false);
markSeen();
}}
>
<span className="sr-only">Close</span>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
</>
);
}

type NotificationsProps = {
info: Info;
};

export default function Notifications(props: NotificationsProps) {
const { info } = props;
const [show, setShow] = useState(false);

const [newNotifications, setNewNotification] = useSessionStorage(
'new_notifications',
info.updateAvailable
);

return (
<>
<Notification
info={info}
show={show}
setShow={setShow}
markSeen={() => setNewNotification(false)}
/>

<button
type="button"
className="without-ring relative rounded-full text-violet-100"
>
{newNotifications && (
<span className="absolute top-0 right-0 flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-violet-100"></span>
</span>
)}
<span className="sr-only">View notifications</span>

<BellIcon
className="h-8 w-6 fill-violet-300 hover:fill-violet-200"
aria-hidden="true"
onClick={() => {
setShow(true);
}}
/>
</button>
</>
);
}
4 changes: 2 additions & 2 deletions src/components/SessionProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createContext, useEffect, useMemo } from 'react';
import { getAuthSelf, getInfo } from '~/data/api';
import { useStorage } from '~/data/hooks/storage';
import { useLocalStorage } from '~/data/hooks/storage';
import { AuthMethodOIDCSelf } from '~/types/Auth';

type Session = {
Expand All @@ -22,7 +22,7 @@ export default function SessionProvider({
}: {
children: React.ReactNode;
}) {
const [session, setSession, clearSession] = useStorage('session', null);
const [session, setSession, clearSession] = useLocalStorage('session', null);

useEffect(() => {
const loadSession = async () => {
Expand Down
47 changes: 40 additions & 7 deletions src/data/hooks/storage.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { Buffer } from 'buffer';
import { useState } from 'react';

export const useStorage = (key: string, initialValue: any) => {
const useStorage = (
key: string,
initialValue: any,
storage: Storage,
encode = false
) => {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
const buffer = item ? Buffer.from(item, 'base64') : null;
return buffer ? JSON.parse(buffer.toLocaleString()) : initialValue;
const item = storage.getItem(key);

if (encode) {
const buffer = item ? Buffer.from(item, 'base64') : null;
return buffer ? JSON.parse(buffer.toLocaleString()) : initialValue;
}

return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
Expand All @@ -18,20 +28,43 @@ export const useStorage = (key: string, initialValue: any) => {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
const buffer = Buffer.from(JSON.stringify(valueToStore));
window.localStorage.setItem(key, buffer.toString('base64'));

let v = JSON.stringify(valueToStore);

if (encode) {
const buffer = Buffer.from(v);
v = buffer.toString('base64');
}

storage.setItem(key, v);
} catch (error) {
console.error(error);
}
};

const clearValue = () => {
try {
window.localStorage.removeItem(key);
storage.removeItem(key);
} catch (error) {
console.error(error);
}
};

return [storedValue, setValue, clearValue];
};

export const useSessionStorage = (
key: string,
initialValue: any,
encode = false
) => {
return useStorage(key, initialValue, window.sessionStorage, encode);
};

export const useLocalStorage = (
key: string,
initialValue: any,
encode = false
) => {
return useStorage(key, initialValue, window.localStorage, encode);
};
3 changes: 2 additions & 1 deletion src/types/Meta.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface Info {
version: string;
latestVersion: string;
latestVersion?: string;
latestVersionURL?: string;
commit: string;
buildDate: string;
goVersion: string;
Expand Down

0 comments on commit 8ec6676

Please sign in to comment.