Description
A social media application, powered by Firebase, with Firebase Authentication, Cloud Firestore NoSQL database, and Cloud Storage. Apart from Firebase, the app consists of 100% client-side code, built upon rigorous database security rules that prevent direct-access attacks using otherwise public credentials. Built with TypeScript, React, and Tailwind CSS, it is fully responsive and highly accessible.
Create/edit/delete your own posts; like/reply to existing posts; follow other users; view recent posts by the users you follow, by a specific user, or by all users on the platform; see recommendations chosen based on the users you have recently seen and those who have recently posted; easily update your username, full name, email, and password; and more.
There is also a matching seeding program, written in Node.js, which generates fake users and posts. Users are created, given random interests and behaviours, and then interact with the site in a semi-realistic fashion (creating/editing/deleting posts/replies, liking posts, following users with similar interests, etc.). New post content is generated by selecting a random interest, or occasionally an unknown topic, extracting a random excerpt from a relevant book to be used as the message, and selecting a random relevant image from Unsplash to be used as the attachment.
Features
Database limits:
- Can handle an infinite number users and posts.
- A user can have an infinite number of followers.
- A user can follow up to approx. 36,000 other users.
- A user can like up to approx. 50,000 posts.
- A post can have an infinite number of likes.
- A post can have up to approx. 50,000 replies, or 25,000 deleted replies.
Pages:
- Two infinite scrolling timelines, including "following" and "explore", where "explore" includes all posts made on the platform in reverse chronological order. On error, such as when offline, a "load more" button will appear at the bottom of these page.
- Profile pages displaying each user's posts, liked posts, and number of followers. When viewing the current user's profile, this page also shows their full name, email, recommendations, following, and includes a form for editing user/login details.
- A dedicated page for each post, where the entire post can be seen in full along with any replies.
Post Modals:
- Expanding a post opens it as a modal, whilst silently pushing the user to the full post page URL. This allows for easy sharing of posts without having to navigate away from the timeline, since a user only has to copy and share the current page address. Following this URL directly will take the user to the full post page.
- The browser's back and forward buttons can be used to close and reopen modals respectively.
- Opening a modal from within an existing modal will replace the current location in history, such that the browser's back button will always return a user to the background page and not reopen previous modals.
Viewing posts:
- Interacting with a timeline/reply post will generally open the full post as a modal (excluding any replies). If a user clicks "view replies", it will open a dedicated post page where the reply thread can be seen in it's entirety.
- Replies will appear in the reply thread of the original post as well as in any threads which the original post is a part of.
- Replies, when listed in a reply thread, will have a "view attachment" button in place of their attachment. Clicking this will open the full post as a modal.
- On post pages replies are paginated. More replies can be loaded by clicking "view replies"/"show more replies". If a new reply has been made since the page was loaded a "load more replies" button will appear.
- Each post/reply displays the time since it was posted/edited, which is set on page load and updates at fixed transition points dependent on how recent the action occurred. The time is initially updated every minute and then every hour. After 24 hours it will show the date without the year and after a year the date will include the year. Hovering over the shortened time/date displays the exact date and time that it was posted/edited as a tooltip.
- Each post/reply has a meatball menu for quick access to some actions, including options to edit and delete/undelete the post, given the current user is the post owner.
- User details are cached for a short amount of time, so that when grabbing multiple posts/comments by the same user their username is not repeatedly requested from the database.
Composing/editing posts:
- Posts must have either a message, an attachment, or both.
- Attachments are resized and compressed before uploading to Firebase Cloud Storage.
- When entering a message, the character count indicator updates and if the 2000 character limit is exceeded then overflowing characters are highlighted. No further characters will register once the upper 3000 character limit is reached.
- Users can take advantage of the emoji-picker when entering messages. This is a third-party component that has been adapted to be fully responsive and compatible with tailwind classes.
Recommendations:
- Recommendations are chosen based on a combination of recently seen and recently posted users. These are cached for up to 10 minutes.
Accessibility:
- Modals and menus all include focus traps, which prevent accidental keyboard navigation outside of the modal/menu. Tab navigation can be used as normal, whilst menus additionally allow arrow key navigation. Upon closing a modal/menu, the focus is returned to whichever element was in focus prior to opening the modal/menu. This is all implemented via a custom component in the case of menus and courtesy of react-modal in the case of modals.
Responsiveness:
- Within timelines and when viewed as replies, post messages are truncated with a fade out and a "read more" button. This happens responsively, such that messages are truncated only if they would otherwise exceed four lines of text. The number of lines is recalculated upon component resize.
- Modals become full screen on small viewports. As such, they appear to be a separate page. Since opening a modal counts as navigation, clicking the back button returns the user to their original location as expected.
- Modals and menus have fluid scrolling where content would otherwise overflow.
- A hamburger menu is rendered on small viewports, in place of header links and buttons.
Firebase:
- User inputs are validated and character limits are enforced both on the client side and via rigorous database security rules on the server side.
- Size limits for attachments are enforced on the server side in order to prevent bypassing of client-side image compression.
- Includes secure by design, rigid, and robust server-side database rules (relatively so, given the limitations of a one-man project).
- Private user data is only accessible to that data's owner and, along with public data, can only be edited by said owner. The only exception is when updating the followersCount field of another user as part of an atomic update. Similarly, a post can only be edited by the owner of said post.
- A post's content and the ID of it's owner are stored separate from the rest of the post details, such that they are only not accessible, other than to the post's owner, once the post is deleted. In short, a user cannot identify the original owner of a post once it has been deleted.
- Atomic updates are enforced server-side. For example, to follow/unfollow another user: the user being followed must not be the current user; the current user must add/remove a single document to/from the followed user's followers sub-collection, with an ID equal to their own user ID; and the follower count on the followed user must be incremented/decremented by the same number of documents added/removed. If any one of these constraints is not met then the database update will fail and none of the changes will be allowed to go through. A similar approach is taken for adding/removing likes on a post. This prevents a user from artificially inflating their follower count via direct database manipulation. It also avoids the need for cloud functions, which arguably don't work well for counters anyway since cloud functions should always be idempotent and an increment/decrement function is obviously not idempotent.
- No fan-out of post data is necessary, thus avoiding any related inherent scaling problems. When a post is created, only 3 writes are made to the database: one to the owner's user document, to update a lastPostedAt field; one to create a document containing public post data; and one to create a document containing the post content. Note that there are some additional read requests made during this step on the server-side, in order to ensure that the correct fields are updated atomically. If the owner of the post has followers, there is no update made to any collections associated with those followers. When a user views their following timeline, the algorithm used, along with indexing in Firestore, results in an average of only 2 document read requests per displayed post.
TypeScript:
- 12,000+ lines of TypeScript, including tests.
Test driven development:
- Thorough testing via Jest and React Testing Library.
Limitations
Most of the following limitations could be solved with Firebase Cloud Functions. Cloud functions are available as part of the Firebase platform but have not been used here since they are not included in the free plan. Client-side code and database security rules are much more complicated as a result of this choice, however this does force a "secure by design" attitude thereby reducing the likelihood of database exploits.
- Some things cannot be controlled by database rules alone, such as enforcing that a post was actually created when the "lastPostedAt" field on a user is updated. Creating a post does enforce that this field is updated to the real current time, but the reverse cannot be enforced without use of cloud functions. This is still enforced client-side, but anyone accessing the database directly could potentially keep themselves at the top of the recommendations list without actually posting. This isn't a huge issue, as they would still have to continuously update the value and could easily just create a new post to achieve the same effect anyway. If they were to immediately delete said post then it would results in an outcome effectively no different from abusing the aforementioned database rules limitation.
- Cooldowns for liking/creating/updating posts and creating/updating users are only enforced client-side. Enforcing these in the database rules is technically possible, however, since the order in which requests are resolved cannot be guaranteed, the user may see a generic database permissions error rather than a custom one.
- Firebase Firestore does not currently make it possible to define database security rules with custom error messages. In most cases though this isn't a huge problem, as validity checks occur on the client side first and security rules are used only as a last line of defence.
- Username availability checks are only enforced on the client side, thus allowing for the creation of multiple accounts with the same username via direct database manipulation. This could be prevented by keeping a separate list of taken usernames for use within database security rules, but this has not yet been implemented.
- Updates to both the authentication system and the database cannot happen atomically and instead occur sequentially. This is a limitation of Firebase and unfortunately means that a user may be left in a state where they are unable to like/create posts or follow other users if they lose connection during sign-up. Similarly, if a user loses connection whilst updating their account details, their profile may display a different email address to the one they sign in with. This would also be the case if a user directly edited their email in the database, and not in the authentication system, which is again possible due to this disconnect between services.
- Due to the lack of atomic authentication/database updates, any user who bypasses validity checks during sign up could end up in a similar position to the one mentioned above. This isn't a huge issue though, as you reap what you sow.
Inspiration
The inspiration for this project came from Karl Hadwen's Instagram clone, which is mostly presentational with some basic Firebase functionality. Almost all of the above listed features of this project are in addition to those demonstrated by Karl's original app.
When I came across Karl's project, I was specifically looking for a way to show off my recently acquired TypeScript skills. I also knew that, whatever it was that I was going to create, I wanted it to include unit testing right from the beginning. Learning Tailwind CSS wasn't part of my immediate roadmap, but I decided to give it a go after seeing Karl use it to good effect. On the whole, it turned out to be a surprisingly pleasant and enjoyable experience and I will definitely be using Tailwind more going forward.
By the end of this project I was extremely familiar with TypeScript, although I still needed more experience with custom utility types and concepts such as type inference, as TypeScript can be extremely powerful once you are proficient with everything it has to offer.
I initially planned to spend only a few weeks on this, but the specification quickly grew out of control and I eventually had to stop implementing any new features.