Using localStorage to save the image and display it on each visit
Table of Contents
I have been working on updating the web client for opsdroid. This new version comes with a new UI and some nice features, such as a theme selector, allowing you to set your username and profile picture.
The problem with the web client is that I didn't want to run a backend or have opsdroid save the user's avatar. Ideally, a user would be able to upload their picture from the client settings, and when a user sends a message to opsdroid, the image should be the one that was uploaded.
Attack Plan
The idea is pretty straightforward to implement. This was what I started with:
- An input that allows users to upload their image
- Convert the image into base64
- Save the base64 in state
- Update the message component to use that image
- Make sure the image persists on new sessions/reloads
Implementation
The web client is written in React/Typescript, so the example I will give here will use the same stack. This is worth noting because when handling files, you need to dance with typing to make typescript happy.
We are also using pullstate as our main state manager, but in this article, I'll use React's useState hook to make it easier to understand.
Creating the Component
Since we will be using useState
we need to create a type for the state.
typescript1type userSettingsState = {2 username: string3 avatar?: string4}
We will set a default for username
, but not for avatar
since this will be the base64 string for the uploaded image. This means that avatar
will be either undefined | string
.
Let's now build the UserSettings
component and use our useSettingsState
.
typescript1type userSettingsState = {2 username: string3 avatar?: string4}5
6export const UserSettings = (): React.ReactElement => {7 const [userSettings, setUserSettings] = React.useState<userSettingsState>({8 username: "bob-the-builder",9 })10
11 const updateUsername = (e: React.FormEvent<HTMLInputElement>) => {}12
13 const updateAvatar = (e: React.FormEvent<HTMLInputElement>) => {}14
15 return (16 <div>17 <h2 className="flex align-items-center">User Settings</h2>18 <div className="flex flex-column padding-left">19 <div className="flex align-items-center">20 <p className="padding-right settings-text">Username</p>21 <input22 type="text"23 id="username"24 placeholder={username}25 defaultValue={username}26 onChange={updateUsername}27 />28 </div>29 <div className="flex align-items-center">30 <p className="padding-right settings-text">Avatar</p>31 <input type="file" onChange={updateAvatar} />32 </div>33 </div>34 </div>35 )36}
Creating the helper functions for state
We now have two functions to fill, updateUsername
and updateAvatar
. All we need to do for the username is get the value from the input.
typescript1const updateUsername = (e: React.FormEvent<HTMLInputElement>) => {2 setUserSettings({3 username: e.currentTarget.value,4 // set avatar to whatever avatar is set5 avatar: avatar,6 })7}
This one was pretty straightforward. The fun begins when we try to update the user's profile picture.
Since we are using an input to upload the image, we can get the image details with e.target.files[0]
, but this doesn't work since typescript will throw an error at you saying that you are trying to access an attribute (files) that doesn't exist in the object.
typescript1Property 'files' does not exist on type 'EventTarget'.
The trick here is setting the type of e.target
to HTMLInputElement
, by doing this, we can then access the files property.
typescript1const updateAvatar = (e: React.FormEvent<HTMLInputElement>) => {2 const target = e.target as HTMLInputElement3 const avatar = target.files[0]4}
This will still give you an error since files is possibly null
. To fix that, we can check if target.files
exists and if it has any element inside.
typescript1const updateAvatar = (e: React.FormEvent<HTMLInputElement>) => {2 const target = e.target as HTMLInputElement;3 if (target.files && target.files.length) {4 const avatar = target.files[0];5}
Now that we have the image file details, we can use FileReader to get the representation of the image as a base64 encoded string. Once we have the base64 encoded string, we can update our userSettings
state with this. Note that FileReader methods are async.
typescript1const updateAvatar = (e: React.FormEvent<HTMLInputElement>) => {2 const target = e.target as HTMLInputElement;3 if (target.files && target.files.length) {4 const avatar = target.files[0];5 const reader = new FileReader();6 reader.readAsDataURL(avatar);7 reader.onload = () => {8 const result = reader.result as string;9 if (result) {10 setUserSettings({11 username: username,12 avatar: result13 })14 }15 };16 }
Persisting data
With opsdroid web client, we are persisting a lot of data through cookies, although we won't be able to store the base64 encoded string of the user profile picture because this string is too large. The option here is using local storage to persist the profile picture and fetch it when we reload/restart the client.
typescript1const updateAvatar = (e: React.FormEvent<HTMLInputElement>) => {2 const target = e.target as HTMLInputElement;3 if (target.files && target.files.length) {4 const avatar = target.files[0];5 const reader = new FileReader();6 reader.readAsDataURL(avatar);7 reader.onload = () => {8 const result = reader.result as string;9 if (result) {10 setUserSettings({11 username: username,12 avatar: result13 })14 localStorage.setItem("profile-picture", result)15 }16 };17 }
We can then update our initial state to get the data from local storage if it exists.
typescript1const [userSettings, setUserSettings] = React.useState<userSettingsState>({2 username: localStorage.getItem("username") || "bob-the-builder",3 avatar: localStorage.getItem("profile-picture") || undefined,4})
That's all
Now users can upload images, and we can save them to local storage and show them when the users come back. It's much better to have a backend so you can save and fetch the image. But, for this use case, local storage seems good enough.
Here's the full code:
typescript1type userSettingsState = {2 username: string;3 avatar?: string;4}5
6export const UserSettings = (): React.ReactElement => {7
8 const [ userSettings, setUserSettings] = React.useState<userSettingsState>({9 username: localStorage.getItem("username") || "bob-the-builder",10 avatar: localStorage.getItem("profile-picture") || undefined11 });12
13 const updateUsername = (e: React.FormEvent<HTMLInputElement>) => {14 setUserSettings({15 username: e.currentTarget.value,16 // set avatar to whatever avatar is set17 avatar: avatar18 })19 };20
21 const updateAvatar = (e: React.FormEvent<HTMLInputElement>) => {22 const target = e.target as HTMLInputElement;23 if (target.files && target.files.length) {24 const avatar = target.files[0];25 const reader = new FileReader();26 reader.readAsDataURL(avatar);27 reader.onload = () => {28 const result = reader.result as string;29 if (result) {30 setUserSettings({31 username: username,32 avatar: result33 })34 localStorage.setItem("profile-picture", result)35 }36 };37 }38
39 return (40 <div>41 <h2 className="flex align-items-center">42 User Settings43 </h2>44 <div className="flex flex-column padding-left">45 <div className="flex align-items-center">46 <p className="padding-right settings-text">Username</p>47 <input48 type="text"49 id="username"50 placeholder={username}51 defaultValue={username}52 onChange={updateUsername}53 />54 </div>55 <div className="flex align-items-center">56 <p className="padding-right settings-text">Avatar</p>57 <input58 type="file"59 onChange={updateAvatar}60 />61 </div>62 </div>63 </div>64 );65};