diff --git a/README.md b/README.md index 2f2931c3..8f5f43c6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ We hope for the Live Site to ## 💯 epic features -- [ ] Web notifications/announcements +- [x] Web notifications/announcements - [ ] Ability to filter what types of announcements one may wish to receive (e.g. only notify me about workshops) - [x] Countdown - [ ] Event schedule diff --git a/src/App.js b/src/App.js index 0747dfbd..825074a1 100644 --- a/src/App.js +++ b/src/App.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect } from 'react' import { Route } from 'wouter' import GlobalStyle from './theme/GlobalStyle' import ThemeProvider from './theme/ThemeProvider' @@ -11,8 +11,37 @@ import { Schedule } from './pages' import Page from './components/Page' +import { db } from './utility/firebase' +import { DB_COLLECTION, DB_HACKATHON } from './utility/Constants' +import notifications from './utility/notifications' + +// only notify user if announcement was created within last 5 secs +const notifyUser = (announcement) => { + const isRecent = new Date() - new Date(announcement.timestamp) < 5000 + if (isRecent && notifications.areEnabled()) { + notifications.trigger("New Announcement", announcement.content) + } +} function App() { + useEffect(() => { + const unsubscribe = db + .collection(DB_COLLECTION) + .doc(DB_HACKATHON) + .collection('Announcements') + .orderBy('timestamp', 'desc') + .onSnapshot(querySnapshot => { + // firebase doc that triggered db change event + const changedDoc = querySnapshot.docChanges()[0] + + // don't want to notify on 'remove' + 'modified' db events + if (changedDoc && changedDoc.type === 'added') { + notifyUser(changedDoc.doc.data()) + } + }) + return unsubscribe + }, []) + return ( <> diff --git a/src/assets/notification-icon.ico b/src/assets/notification-icon.ico new file mode 100644 index 00000000..2ce2b48d Binary files /dev/null and b/src/assets/notification-icon.ico differ diff --git a/src/components/Announcements.js b/src/components/Announcements.js index baeaacca..550f9493 100644 --- a/src/components/Announcements.js +++ b/src/components/Announcements.js @@ -4,9 +4,10 @@ import { format } from 'timeago.js'; import ReactMarkdown from 'react-markdown'; import { Card } from './Common'; import { H1, P, A } from './Typography'; +import NotificationToggle from '../containers/NotificationToggle'; const StyledH1 = styled(H1)` - margin: 0 0 0.5em 0; + margin: 0 0 0 0; ` const StyledP = styled(P)` @@ -21,9 +22,19 @@ const Announcement = styled.div` margin: 1em 0; ` +const AnnouncementHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin: 0 0 1em 0; +` + export default ({ announcements }) => ( - Announcements + + Announcements + + { announcements.map(announcement => { const timeAgo = format(announcement.timestamp) diff --git a/src/components/ToggleSwitch.js b/src/components/ToggleSwitch.js new file mode 100644 index 00000000..99098ae6 --- /dev/null +++ b/src/components/ToggleSwitch.js @@ -0,0 +1,87 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; + +const ToggleSwitchContainer = styled.div` + display: inline-block; +` + +const ToggleSwitchGraphic = styled.div` + width: 35px; + height: 30px; + background: ${p => p.theme.colors.foreground}; + z-index: 0; + cursor: pointer; + position: relative; + border-radius: 50px; + line-height: 40px; + text-align: right; + padding: 0 10px; + bottom: 10px; + transition: all 250ms; + + &:before { + content: ''; + display: inline-block; + position: absolute; + left: 4px; + bottom: 4px; + height: 22px; + width: 22px; + background: #DFDCE5; + border-radius: 50%; + transition: all 400ms; + } + + &:after { + content: ''; + display: inline-block; + } + + ${p => + p.disabled && + css` + cursor: not-allowed; + opacity: 0.3; + `}; +` + +const Input = styled.input` + visibility: hidden; + + &:checked + ${ToggleSwitchGraphic} { + background: ${p => p.theme.colors.primary}; + text-align: left; + } + + &:checked + ${ToggleSwitchGraphic}:after { + left:52px; + } + + &:checked + ${ToggleSwitchGraphic}:before { + content: ''; + position: absolute; + left: 30px; + border-radius: 50%; + } +` + +const ToggleSwitch = ({ checked, disabled, disabledTooltip, onChange }) => { + return ( + + + + ); +}; + +export default ToggleSwitch; \ No newline at end of file diff --git a/src/containers/Announcements.js b/src/containers/Announcements.js index dd80f06b..1bfc81fd 100644 --- a/src/containers/Announcements.js +++ b/src/containers/Announcements.js @@ -21,4 +21,4 @@ export default () => { }, [setAnnouncements]) return announcements.length ? : null -}; \ No newline at end of file +}; diff --git a/src/containers/NotificationToggle.js b/src/containers/NotificationToggle.js new file mode 100644 index 00000000..5deb0655 --- /dev/null +++ b/src/containers/NotificationToggle.js @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { H2 } from '../components/Typography'; +import ToggleSwitch from '../components/ToggleSwitch'; +import notifications from '../utility/notifications'; +import { + NOTIFICATION_PERMISSIONS as N_PERMISSIONS, + NOTIFICATION_SETTINGS_CACHE_KEY as N_SETTINGS_CACHE_KEY +} from '../utility/Constants'; + +const NotificationToggleContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; +` + +const StyledH2 = styled(H2)` + margin: 0 0 0 0.5em; + opacity: 1; +` + +export default () => { + const [toggled, setToggled] = useState(false) + + useEffect(() => { + setToggled(notifications.areEnabled()) + }, [setToggled]) + + const handleToggle = (e) => { + // if user's first time on site, request notification permissions from browser + if (notifications.isCurrentPermission(N_PERMISSIONS.DEFAULT)) { + notifications.requestPermission(permission => { + toggleNotifications(permission === N_PERMISSIONS.GRANTED) + }) + } + + toggleNotifications(!toggled) + } + + // toggle switch UI and cache notifications settings + const toggleNotifications = (notificationsEnabled) => { + setToggled(notificationsEnabled) + + const nSettingsJSON = JSON.stringify({ notificationsEnabled }) + localStorage.setItem(N_SETTINGS_CACHE_KEY, nSettingsJSON) + } + + return ( + + + Notifications + + ); +}; diff --git a/src/utility/Constants.js b/src/utility/Constants.js index 5b8be281..cfb33d1c 100644 --- a/src/utility/Constants.js +++ b/src/utility/Constants.js @@ -1,4 +1,10 @@ export const DB_COLLECTION = 'Hackathons' export const DB_HACKATHON = 'LHD2021' export const FAQ_COLLECTION = 'FAQ' -export const DAYOF_COLLECTION = 'DayOf' \ No newline at end of file +export const NOTIFICATION_SETTINGS_CACHE_KEY = 'livesiteNotificationSettings' +export const NOTIFICATION_PERMISSIONS = Object.freeze({ + GRANTED: 'granted', + DEFAULT: 'default', + DENIED: 'denied', +}) +export const DAYOF_COLLECTION = 'DayOf' diff --git a/src/utility/notifications.js b/src/utility/notifications.js new file mode 100644 index 00000000..46b51e37 --- /dev/null +++ b/src/utility/notifications.js @@ -0,0 +1,46 @@ +import icon from "../assets/notification-icon.ico"; +import { + NOTIFICATION_SETTINGS_CACHE_KEY, + NOTIFICATION_PERMISSIONS +} from "./Constants"; + +const requestPermission = (permissionCallback) => { + if (checkNotificationPromise()) { + Notification.requestPermission().then(permissionCallback) + } else { + Notification.requestPermission(permissionCallback) + } +} + +// need this for safari support +const checkNotificationPromise = () => { + try { + Notification.requestPermission().then(); + } catch (e) { + return false; + } + return true; +} + +const isCurrentPermission = (permission) => { + return Notification.permission === permission +} + +const areEnabled = () => { + const settingsJSON = localStorage.getItem(NOTIFICATION_SETTINGS_CACHE_KEY) + const settings = settingsJSON ? JSON.parse(settingsJSON) : null + return settings + && settings.notificationsEnabled === true + && Notification.permission === NOTIFICATION_PERMISSIONS.GRANTED +} + +const trigger = (title, body) => { + new Notification(title, { body, icon }); +} + +export default { + requestPermission, + isCurrentPermission, + areEnabled, + trigger +} \ No newline at end of file