HxSkits: Crafting Instant Magic with React PWAs and Firebase Notifications πŸͺ„πŸ””

Learn how I created a PWA with push notifications using React and Firebase.

HxSkits: Crafting Instant Magic with React PWAs and Firebase Notifications πŸͺ„πŸ””

Introduction 🎀: A World of Online Comedy

In the ever-entertaining realm of online streaming, where laughter reigns supreme, one personality stood out - Hyphonix. This online comedy virtuoso specialises in orchestrating hilarious pranks on Omegle, the platform where video chats and unexpected connections take centre stage.

Being a dedicated subscriber, I closely followed Hyphonix's journey. He possessed the unique talent of whisking his audience away on a rollercoaster of fun. The premise was simple but genius: one of his viewers, let's call them 'A,' would initiate a conversation with an Omegle user, often referred to as 'the mark.' They would casually gather some seemingly mundane details - perhaps a name or a favourite song.

Here's where the magic began. Hyphonix, our puppet master, would seamlessly switch out viewer 'A' for viewer 'B,' who'd continue the charade as a new stranger. This relay would continue, with viewer 'C' taking their turn in the act. Then, the grand reveal: 'C' would drop the collected information, leaving the Omegle user in delightful bewilderment.

This masterpiece of chaos and comedy was aptly named the 'Community Name Skit.' Get a sneak peek here when the skit is perfectly executed:

However, Hyphonix faced challenges in executing this skit flawlessly. Viewers often stumbled, revealing information too soon or failing to play the part of genuine strangers. Hyphonix's attempts to streamline the process encountered roadblocks:

  1. Trying to display information on the screen using OBS software was thwarted by Omegle's watchful eye, leading to IP bans.

  2. Relaying instructions through Discord messages had its limitations, specifically onboarding users resistant to signing up for Discord and unfamiliar with the platform

  3. Resorting to YouTube chat proved a cumbersome endeavour, juggling pinned instructions while coordinating the show.

Based on this background and the previous stopgaps, I distilled the objectives of any viable solution into the following goals:

  1. Streamline viewer participation in Omegle pranks, bypassing the onboarding process that could slow down recruits to Discord.

  2. Empower viewers with straightforward, instant, foolproof instructions, ensuring they have all the ammunition necessary to leave unsuspecting Omegle users in awe

My understanding of the project's goals mainly came from watching those live streams. But, I'll spill the beans on a major mistake I made in the conclusion.

UX Design πŸ—ΊοΈ: Crafting a Hilarious Visual Tale

To get a grip on the technical nuts and bolts needed to make our dreams come true, I embarked on a wild journey of storyboarding. Check out this colourful storyboard that shows the expected experiences needed to achieve Goals #1 and #2.

A storyboard showing the typical experience provided by using the Hx Skits App Including Hyphonix, 2 of his viewers and a stranger named Poopies.

This storyboard spilt the beans on two must-have features:

  1. Real-Time Chatter: Our app had to facilitate lightning-fast conversations between an ultra-exclusive Admin hideout and the broader audience. The Admin hotspot, a realm for Hyphonix and his playful moderators, served as the hub for skit wizardry, with updates and laughter flying at the speed of light.

  2. Laid-Back Onboarding: We couldn't skip out on an onboarding process that's as smooth as butter on a hot pancake. No matter which tech wizardry I chose, the goal was crystal clear: I wanted users to dive into the name skit frenzy with ease, with no strings attached!

Tech Selection πŸ‘¨πŸΎβ€πŸ’»: Building Hilarity with a Tech Stack

To put theory into practice, I set out to craft a React Progressive Web Application for the front end. The driving force behind this choice was Goal #2: A Seamless Onboarding Experience. At the time (not anymore πŸ₯²), I firmly believed that the smoothest onboarding process could be achieved by turning the website into an installable application. The Progressive Web Application (PWA) framework seemed like the perfect tool for this job, but, as I elaborate on you'll see PWAs still had more than their fair share of quirks.

Transitioning from the frontend to the backend, I needed a robust foundation to support Goal #1: Real-Time Chatter. To tackle this, I harnessed Express to create APIs and laid out a plan to employ Firestore, Firebase Functions, and Cloud Messaging. This would facilitate the flow of notifications from the Admin section to the general user base. It's a bit of a broken record, I know, but as you'll soon discover, the PWA's idiosyncrasies forced some nimble adjustments along the way.

🧠
Average experience trying to build a browser-independent PWA

UI Design πŸ–ΌοΈ: Crafting a Hilarious Visual Tale

In the realm of UI design, the mission was crystal clear - to craft a straightforward visual roadmap showcasing the admin and client-side interfaces. This encompassed detailing the user-friendly onboarding journey, including the installation process as a PWA, and accounting for those tricky edge cases. If you're curious to take a peek, venture here.

Despite my meticulous documentation of the project's features and requirements, I couldn't resist the allure of scope creep. This meant I also created a system that enabled admins to notify general users when Hyphonix went live, revealing his current platform and activity, be it the iconic name skit or simply hanging out with viewers

Sample UI Design displaying the General Home Page

While my design system might not have been my magnum opus, it did entail the intricate design of elements like Modals, Buttons, Inputs, Navbars, and an array of other components. Should you desire a closer look, mosey on over here.

Diving into the Nuts and Bolts πŸ”©: The Front End

Tech used
React, Tailwind, Vite, Framer-Motion, Firebase Client SDK, Service Workers, Axios, React Router, Typescript, Vercel

As I dive into the fascinating world of React PWAs and Express APIs, it's important to clarify that this isn't a step-by-step guide for replicating my project. I won't dissect every line of code, but don't worry, I'll shine a light on the most critical code blocks. If you're itching to explore the full codebase, swing by the GitHub repositories for the Front End and Back End.

Kicking off the React PWA, I decided to use the Vite library. Despite being aware of state management tools like Redux and Zustand, I also took a different path and built my global state container using a React context I named 'App Data Context.' I persisted the context by manually storing it in local storage. When state updates occurred, I played the comparison game between the App Data Context's state and the state in local storage, making necessary updates along the way. This whole process gave birth to a custom hook called 'useSetAppData,' which made state updates a breeze by reconciling differences between in-memory and local storage states.

//Store of the entire app's data with it's defined shape
export function AppDataContextProvider({ children }: { children: React.ReactNode }) {
    const [appData, setAppData] = useState(emptyAppData)
    //instantiate app's data into state memory if it exists and create it if it doesn't exist.
    useEffect(() => {
        const initiliazeAppData = async () => {
            let appDataResult = await getAppDataInLocalStorage()

            if (!appDataResult) {
                console.log("initializing app data")
                //Data doesn't exist and is set to false
                setAppDataInLocalStorage(appData)
            }

            if (appDataResult) {
                //Data exists
                console.log("Setting app data from local storage")
                setAppData(appDataResult)
            }
        }

        initiliazeAppData()

    }
        , [])

    return (
        <AppDataContext.Provider value={{ appData, setAppData }}>
            {children}
        </AppDataContext.Provider>
    )

}

//Defintion of the useSetAppData Hook
export const useSetAppData = () => {
    const { setAppData } = useContext(AppDataContext)
    const updateAppDataWithShallowComparison = useUpdateAppDataWithShallowComparisonIgnoringTime()

    /**
     * This function sets the app's data in react's memory and in local storage
     * @param newData The new app data to set
     */
    const setAppDataCallback = useCallback((newData: appDataInterface) => {
        setAppData(newData)
        updateAppDataWithShallowComparison(newData)
    }, [setAppData])

    return setAppDataCallback

}

One of the fantastic perks of using Vite for my PWA project was its plugin-driven setup. These plugins swiftly orchestrated vital features such as configuring the PWA's manifest and crafting a strategy for configuring the service worker.

The first major hiccup on the PWA journey was realizing that not all web browsers played nicely with the "beforeinstallprompt" event. I needed to control the event to explain to users why they should install the HxSkits web app. I also needed the 'beforeinstallprompt' to trigger the installation process using the event's "prompt" method. It became clear that even when some Chromium desktop browsers embraced the "beforeinstallprompt" event, their mobile counterparts weren't quite as cooperative – think Microsoft Edge on desktop versus the mobile versions.

Now, when it came to Safari, things took an interesting twist. Users had to bookmark the site before being able to trigger this event. Nevertheless, I pressed on, aiming to support browsers that played nice across the board. This meant dancing to Safari's somewhat convoluted installation process. I also had to initially exclude browsers that didn't support PWA APIs, like Opera, Firefox (Sorry Mozilla πŸ₯²), Samsung Internet Browser and so on.

// This function checks if a browser supports PWA by checking
// if it supports certain features
export const checkIfBrowserSupportsPwa = () => {
    /**
    * This variable is used to store the result of the check for PWA support
    */
    let isPwaSupported: boolean = false

    const hasAdvancedUserExperience = 'serviceWorker' in navigator;
    const hasWebPushNotifications = 'PushManager' in window;
    const hasHomeScreen = 'BeforeInstallPromptEvent' in window;
    const hasGeolocation = 'geolocation' in navigator;
    const hasVideoImageCapturing = 'ImageCapture' in window;
    const hasBluetooth = 'bluetooth' in navigator;

    // Add this line to exclude Samsung Internet Browser, Opera browser, 
    // firefox and other browsers with dodgy pwa support
    const excludedBrowsers = ['SamsungBrowser', 'OPR', 'Firefox', "Opera"];
    if (excludedBrowsers.some((browser) => navigator.userAgent.includes(browser))) {
      return false;
    }

    if (hasAdvancedUserExperience && hasWebPushNotifications && hasHomeScreen && hasGeolocation && hasVideoImageCapturing && hasBluetooth) {
    isPwaSupported = true
    }

    return isPwaSupported
   }
// This function does a PWA support check and returns the
// browser's name
export default function getUserBrowsersDetails() {
    const userAgent = navigator.userAgent;
    let browserName: string = "unknown";
    let doesBrowserSupportPwa: boolean = checkIfBrowserSupportsPwa();

    if (userAgent.includes("Firefox")) {
        browserName = "firefox";
    } if (userAgent.includes("SamsungBrowser")) {
        browserName = "samsung internet";
    } if (userAgent.includes("Opera") || userAgent.includes("OPR")) {
        browserName = "opera";
    } if (userAgent.includes("Trident")) {
        browserName = "internet explorer";
    }
    if (userAgent.includes("Chrome")) {

        if (checkBrowserBrandName("Brave")) {
            browserName = "brave"
        }

        if (checkBrowserBrandName("Google Chrome")) {
            browserName = "chrome"
        }

    }
    if (userAgent.includes("Edg")) {
        browserName = checkBrowserBrandName("Microsoft Edge") ? "edge" : "unknown";
    }
    if (userAgent.includes("Safari") && userOs === "ios" || userOs === "macos") {
        browserName = "safari";
    }

    return { browserName, doesBrowserSupportPwa };
}

I moved on, dead set on creating the PWA's service worker to play nice with Vite. This was a big deal because it was the ticket to ensure that the app could receive background messages from Firebase Cloud Messenger. So, I dived into the Vite docs, and I even left a little trail on Stack Overflow, where I spilt the beans on how I built the service worker. All that effort didn't go to waste as the HxSkits app could now receive background messages. This was achieved by passing the FCM token, acquired after registering the app with Firebase, through indexDB.

Yet, my story wouldn't be complete without a major twist – an iOS Webkit bug that left me scratching my head. It turned out that service worker event listeners refused to work when launching the PWA from the home screen. This hurdle forced me to forge a new path, an alternative onboarding route independent of PWA installation.

This choice was driven by the recognition that most of our users would be iPhone and Safari enthusiasts (yes, those Apple devotees πŸ™„) and users using unsupported browsers. Excluding them from using the application wasn't an option. As a result, instead of pushing users to install the site as a PWA, the onboarding process allowed these users to simply receive notifications while it remained open.

const consumeSSE = useGetAndConsumeSSE()
const unsupportedBrowserSSEs = appData.userData.isUserBrowserSupported
        ? null : new EventSource(homeUrl + import.meta.env.VITE_SSE_ADMIN_SUBSCRIPTION)
 //UseEffect for subscribing the unsupported browser to SSE endpoints 
  useEffect(() => {
        if (unsupportedBrowserSSEs) {
            handleSseOnMessage(unsupportedBrowserSSEs)
            return () => {
                unsupportedBrowserSSEs.close()
            }
        }
   }, [unsupportedBrowserSSEs])

This pivot led me down the road of subscribing unsupported client devices to a Server-Sent Event (SSE) endpoint and updating the app's internal context using a custom hook called 'consumeSSE.' This workaround allowed unsupported users to receive notifications as long as the app remained open.

Through all this chaos and commotion, it was the inconsistent PWA standard and those pesky OS-level restrictions that caused all the "wahala." But in the end, the Front End application was in a state where users from all corners of the digital world, regardless of their device or 'browser creed,' could finally come aboard.

Diving into the Nuts and Bolts 🧢: The Back End

  • Tech used: Express.js, body-parser, Express-validator, Passport.js, Firestore, Firebase Cloud Messenger, Firebase functions, Server-Sent Events, Typescript, Heroku

Unlike the front end, working on the back end was a more straightforward journey. I kicked things off by setting up an Express server and crafting endpoints to handle administrator logins. To make this happen, I employed Passport) for authentication and leveraged Cloud Firestore as our database backend. In a nutshell, I designed a local strategy that utilized Firestore to handle the login process. Additionally, I chose to implement JSON Web Tokens (JWT) to manage administrator sessions and secure protected endpoints.

export const verifyUser = () => {
    passport.use(new LocalStrategy(
        async function verify(username: string, enteredPassword: string, cb: Function) {
            try {
                console.log("Running verify function")
                const modCollectionRef = firebaseDB.collection(process.env.DB_MOD_COLLECTION as string)
                let query = await modCollectionRef.where('username', '==', username).limit(1)

                query.get().then(async (querySnapshot: querySnapshot) => {
                    if (querySnapshot.empty) {
                        return cb(null, false, { message: "User does not exist" })
                    } else {
                        querySnapshot.forEach(async (doc) => {
                            if (doc.exists) {
                                const id = doc.id
                                const { username, password } = doc.data()
                                const user = { id, username, password }
                                let doPasswordsMatch = await comparePassword(enteredPassword, password)

                                if (!doPasswordsMatch) {
                                    console.log("Incorrect password")
                                    return cb(null, false, { message: "Incorrect password" })
                                }


                                if (doPasswordsMatch) {
                                    console.log("Passwords match", JSON.stringify(user))
                                    return cb(null, user)
                                }

                            } else {
                                return cb(null, false, { message: "User does not exist" })
                            }
                        })
                    }
                })
            } catch (e) {
                console.error("Failed to get DB")
                return cb(e, false, { message: "Failed to get DB" })
            }
        }))
}

export const verifyJWT = () => {
    passport.use(new JwtStrategy(jwtOptions, async (payload: any, cb: Function) => {
        try {
            const modCollectionRef = firebaseDB.collection(process.env.DB_MOD_COLLECTION as string)
            let query = await modCollectionRef.where('username', '==', payload.username).limit(1)

            query.get().then(async (querySnapshot: querySnapshot) => {
                if (querySnapshot.empty) {
                    console.log("Token Invalid")
                    return cb(null, false, { message: "User Token invalid" })
                } else {
                    querySnapshot.forEach(async (doc) => {
                        if (doc.exists) {
                            const { username } = doc.data()
                            const user = { username }
                            console.log("User's token is valid")
                            return cb(null, user)
                        } else {
                            console.log("Token Invalid")
                            return cb(false, { message: "User Token invalid" })
                        }
                    })
                }
            })

        } catch (e) {
            console.error("Failed to verify JWT")
            cb(e, false, { message: "Failed to verify JWT" })
        }
    }))
}

The next hurdle was devising a notification system. It all started by registering devices from the front end that had permitted browser notifications using a /user/savetoken endpoint. Unsupported browsers simply established an SSE connection using a /user/sse/subscribe endpoint

//Save Token Endpoint
validateFCMRouter.post('/savetoken',
    saveFcmTokenSchema,
    async (req: Request, res: Response) => {

        const result = validationResult(req)


        if (!result.isEmpty()) {
            sendResponse.badRequest(res, "Error", 400, { ...result.array() })
        }

        if (result.isEmpty()) {
            const { token, platform } = req.body
            const tokenData = await getTokenData(token)

            if (tokenData.wasTokenReturned) {
                sendResponse.conflict(res, "Token already exists", 409, { ...tokenData.fcmData })
            }

            if (!tokenData.wasTokenReturned) {
                const newClientsFcmDoc = fcmDetailsCollection.doc()
                const newClientsFcmDocId = newClientsFcmDoc.id
                let hasUserSubscribedToTokens = await subscribeTokenToTopics(token)

                if (!hasUserSubscribedToTokens) {
                    sendResponse.internalError(res, "Failed to subscribe user to topics", 500)
                }

                if (hasUserSubscribedToTokens) {
                    const newClientsFcmDocData: fcmClientDocument = {
                        token: token,
                        platform: platform,
                        addedOn: new Date(),
                        messagelastreceivedon: new Date(),
                        subscribedTo: ["nameskit", "livestream", "omegle"]
                    }

                    newClientsFcmDoc.set(newClientsFcmDocData).then(
                        () => {
                            console.log("New client added to FCM collection")
                            sendResponse.success(res, "New client added to FCM collection", 200, { ...newClientsFcmDocData, id: newClientsFcmDocId })
                        }
                    ).catch((error) => {
                        console.log("Error adding new client to FCM collection: " + error)
                        sendResponse.internalError(res, "Error adding new client to FCM collection", 500)
                    })
                }


            }

        }

    })

// SSE Endpoint
adminRouter.get('/sse/subscribe', (req: Request, res: Response) => {
    //Set headers for the SSE
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    //Send something to establish connection
    res.write('data: {\"trigger\":\"init\"}\n\n');

    //Add the response to the array of clients
    adminSseClients.push(res)

    //Remove the response from the array of clients when the client closes the connection
    req.on('close', () => {
        const indexOfDisconnectedClient = adminSseClients.indexOf(res)
        if (indexOfDisconnectedClient != -1) {
            adminSseClients.splice(indexOfDisconnectedClient, 1)
        }
    })
})

Supported devices would in turn receive tokens that were then linked to specific topics. These topics included a Live Stream topic (for dispatching notifications about Hyphonix's current streaming platform), an Omegle Topic (for alerting users when the tags Hyphonix was using changed), and a Name Skit Topic (for sending users instructions on actions to perform, such as mentioning the mark's name or engaging in playful antics).

For devices that couldn't support Firebase notifications, I had a backup plan using the custom sendSseEvent method.

//Function for subscribing tokens to topics
const subscribeTokenToTopics = async (token: string) => {
    try {
        await Promise.all([
            firebaseAdmin.messaging().subscribeToTopic(token, livestreamTopic),
            firebaseAdmin.messaging().subscribeToTopic(token, nameSkitTopic),
            firebaseAdmin.messaging().subscribeToTopic(token, omegleTopic)
        ]);
        return true;
    } catch (error) {
        console.error('Failed to subscribe user to topics:', error);
        return false;
    }
};

In the realm of FCM notifications, my approach was to have administrators update information in Firestore, triggering a Firebase Cloud Function upon the update. This function would then send messages to the respective topics.

//Sample Firebase function for sending a notification when 
//Administrators updated the livestream DB
export const sendLiveStreamNotification = firebaseFunctions.firestore.document(`${livestreamCollectionString}/${liveStreamDocumentString}`)
    .onUpdate(
        async (change) => {

            try {
                const currentOmegleTags = await getOmegleTags().then(
                    (response) => {
                        if (response && typeof response !== "boolean") {
                            return response.currentOmegleTags.join(", ")
                        } else {
                            return "[]"
                        }
                    }).catch((error) => {
                        console.error("Failed to get Omegle tags", error)
                        return "[]"
                    })
                const newData = change.after.data()
                const dataToBeIncluded = {
                    messageFromEvent: "liveStreamUpdate",
                    liveStreamData: JSON.stringify({
                        streamingOn: newData.streamingOn,
                        activityType: newData.activityType,
                        streamingLink: newData.streamingLink
                    }),
                    currentOmegleTags
                }
                let message: notificationMessage | boolean = false;

                if (newData.streamingOn !== "none") {

                    if (newData.streamingOn === "youtube") {
                        message = generateYoutubeNotificationMessage(newData, dataToBeIncluded);
                    }

                    if (newData.streamingOn === "twitch") {
                        message = generateTwitchNotificationMessage(newData, dataToBeIncluded);
                    }

                } else {
                    message = {
                        topic: liveStreamTopicString,
                        notification: {
                            title: "Hx is no longer live!πŸ˜”",
                            body: `Hx's stream has ended. Don't worry, make sure to check back tomorrow. He streams live everyday!`
                        },
                        data: dataToBeIncluded
                    };
                }

                if (typeof message !== "boolean") {
                    await initializedFirebaseAdmin.messaging().send(message);
                    console.log("Successfully sent live notification", message);
                }
            } catch (error) {
                console.error("Failed to send Notification", error);
            }

        });

The final step involved creating user-facing endpoints, enabling client devices to update data when they loaded the app.

userRouter.get('/livestream/get', (req: Request, res: Response) => {
    const livestreamCollection = firebaseDB.collection(process.env.DB_LIVESTREAM_COLLECTION as string)
    const livestreamMasterDocument = livestreamCollection.doc(process.env.DB_LIVESTREAM_DOCUMENT_ID as string)

    livestreamMasterDocument.get().then(
        (snapshot) => {
            if (snapshot.exists) {
                const livestreamData = snapshot.data() as liveStreamDocument
                sendResponse.success(res, "Success", 200, { ...livestreamData })
            } else {
                sendResponse.notFound(res, "Livestream not found", 404)
            }
        }
    ).catch((error) => {
        console.error("Failed to get livestream", error)
    })
})

With all these pieces in place, I finally deployed the backend to Heroku and proceeded to test it with potential members of the Hyphonix community.

Guerilla testing the AppπŸ”¬: Meeting a Harsh Reality

Drawing from my previous experience as a Senior UX/UI designer, I firmly believe that user testing is the phase where assumptions meet their maker. Through Hyphonix's private Discord, I connected with members of his community and laid out the problem I aimed to tackle, presenting the app as the solution.

To ensure that users could grasp the solution seamlessly, I crafted an onboarding video that provided a comprehensive overview of the app's purpose.

Prominent feedback highlighted that users weren't enthusiastic about installing an app that bombarded them with notifications each time Hyphonix went live. An example of this feedback is displayed below:

Due to my excessive confidence in my perspective as a Hyphonix community member, I overlooked the fact that other users might not share the same enthusiasm for receiving continuous notifications about Hyphonix's activities. It became evident that a pivot was necessary to provide users with more control over notifications, but my initial choice of the PWA platform posed challenges for such a pivot. Additionally, it's worth noting that at this stage, I had started to lose steam and interest in the project.

As a result of this, I made the harsh decision to sunset the application.

Lessons Learned 🧠: The Bumpy Ride of Hx Skits - On Simplicity, Testing, and Project Objectives

Congratulations πŸŽ‰πŸŽ‰πŸ₯³πŸ₯³! You've reached the end of this extensive blog post. As we conclude, I'd like to share some of the valuable lessons I've gained during this journey:

  1. Prioritize UX Testing in Requirements Gathering and Systems Architecture

    Despite my background as a UX designer, I fell into the trap of not validating assumptions and understanding users' needs and goals before diving into code. This led me to create a solution that essentially led to nowhere.

  2. Write Code with Maintenance in Mind, Not Just for Immediate Functionality

    JavaScript's flexibility can sometimes result in code that works but is a tangled mess. This project has taught me the importance of focusing on writing elegant, scalable, and well-structured code. Not only is it easier to read later, but it's also simpler to scale and maintain.

  3. Embrace Testing

    Regardless of the programming language or framework, writing tests is a practice that encourages the creation of better code. Testing not only helps uncover bugs but also aids in decoupling complex code and identifying regression issues. Given the nature of some of my React solutions, particularly the DIY persistent global state, it was essential to adopt a "test early and test often" approach to identify potential breaking issues. Regrettably, I didn't prioritize this, driven by my passion for building.

These lessons have been instrumental in my journey with Hx Skits, and I hope they resonate with your own experiences and projects. Thank you for joining me on this adventure, and I look forward to sharing more insights and experiences in the future.

Β