In this guide, we will show how to use Fission webnative storage to save user data. Our goal will be to add annotations to blog posts in the elm-pages-starter
.
All of the code in this guide is available in the fission-elm-pages-starter repository on the storage
branch.
When a user has signed in with Fission, an annotation button is displayed at the bottom right corner of the page.
Clicking the button opens a text input box where the user can write their notes.
If a user has not signed in, they do not see the annotation button or text input box.
Annotations are saved to the user's webnative filesystem and will be there when they return or use the app on another device.
We will start off by writing an annotation module. Annotation.elm
is built around an Annotation
type and has a way to create, encode, and view annotations.
An Annotation
can be editable or not. If an annotation is editable, it has a title
and notes
.
type Annotation= Editable Internals| NotEditingtype alias Internals ={ title : String, notes : String}
We have two ways of creating an annotation. A NotEditing
annotation can be created directly. An Editable
annotation must be created from a Value
that will come over a port.
notEditing : AnnotationnotEditing =NotEditingfromValue : Value -> AnnotationfromValue val =case Json.Decode.decodeValue internalsDecoder val ofOk internals ->Editable internalsErr _ ->NotEditinginternalsDecoder : Decoder InternalsinternalsDecoder =Json.Decode.map2 Internals(Json.Decode.field "title" Json.Decode.string)(Json.Decode.field "notes" Json.Decode.string)
Our code that interacts with the webnative filesystem will use blog post titles as keys to lookup and save annotations.
Add encode
for storing annotations and encodeTitle
for loading them.
encode : Annotation -> Json.Encode.Valueencode annotation =case annotation ofEditable internals ->Json.Encode.object[ ( "title", Json.Encode.string internals.title ), ( "notes", Json.Encode.string internals.notes )]NotEditing ->Json.Encode.nullencodeTitle : String -> Json.Encode.ValueencodeTitle title =Json.Encode.object[ ( "title", Json.Encode.string title ) ]
Lastly, we need a way to view annotations. When a user is editing an annotation, we show their notes or the blog post title as a placeholder. When an annotation is not being edited, we show a button with a pencil icon.
Add a view
function with a case for Editable
and NotEditing
annotations.
view :{ annotation : Annotation, title : String, onLoadAnnotation : String -> msg, onUpdateAnnotation : Annotation -> msg}-> Element msgview options =case options.annotation ofEditable annotation ->Element.row[ Element.alignBottom, Element.alignRight, Element.padding 30][ Element.Input.multiline[ Element.height (Element.px 150), Element.width (Element.px 200), Border.width 2, Border.roundEach { topLeft = 15, topRight = 15, bottomLeft = 15, bottomRight = 0 }, Border.color (Element.rgb 0.5 0.5 0.5), Element.focused[ Border.color (Element.rgb 0.3 0.3 0.3), Border.shadow{ offset = ( 1, 1 ), blur = 1, color = Element.rgb 0.85 0.85 0.85, size = 1}], Font.size 16]{ onChange =\newText ->options.onUpdateAnnotation(Editable{ title = options.title, notes = newText }), text = annotation.notes, placeholder =Just(Element.Input.placeholder [](Element.text ("Write some notes on " ++ options.title))), label = Element.Input.labelHidden ("Notes on " ++ options.title), spellcheck = False}]NotEditing ->Element.row[ Element.alignBottom, Element.alignRight, Element.padding 30][ Element.Input.button []{ onPress = Just (options.onLoadAnnotation options.title), label =Element.row[ Element.padding 8, Border.width 2, Border.rounded 50, Border.color (Element.rgb 0.3 0.3 0.3)][ Element.image[ Element.width (Element.px 30)]{ src = ImagePath.toString Pages.images.annotation, description = "Annotate with Fission"}]}]
Now that our annotation module is ready, we can add annotations to the elm-pages-starter
.
We want the user to see the annotation button when they navigate to a page with a blog post. When they click on the annotation button, we load the annotation from their webnative filesystem or an empty annotation if they are starting a new annotation. We replace the button with the notes in a text input box.
We save the annotation to webnative with each keystroke the user makes in the text input box.
Import the annotation module in Main.elm
.
import Annotation exposing (Annotation)
Add an annotation
to the Model
.
type alias Model ={ username : Maybe String, annotation : Annotation}
We only display one annotation at a time, which means we can replace annotation
when we navigate to a new page or load an annotation.
The annotation starts off as NotEditing
in init
.
init : ( Model, Cmd Msg )init =( { username = Nothing, annotation = Annotation.notEditing}, Cmd.none)
Our update
will handle four messages that initialize, load, or store annotations. OnPageChange
sets the annotation to NotEditing
when a user navigates to a new page. LoadAnnotation
calls over a port to load an annotation. UpdateAnnotation
updates the displayed annotation in the input box and stores the annotation. GotAnnotatation
subscribes to annotations that come in over a port after a load.
type Msg= SubmittedLogin| GotAuth (Maybe String)| OnPageChange{ path : PagePath Pages.PathKey, query : Maybe String, fragment : Maybe String}| LoadAnnotation String| UpdateAnnotation Annotation| GotAnnotation Annotationupdate : Msg -> Model -> ( Model, Cmd Msg )update msg model =case msg ofSubmittedLogin ->( model, login ())GotAuth maybeUsername ->( { model | username = maybeUsername }, Cmd.none)OnPageChange _ ->( { model | annotation = Annotation.notEditing }, Cmd.none)LoadAnnotation title ->( model, loadAnnotation (Annotation.encodeTitle title))UpdateAnnotation annotation ->( { model | annotation = annotation }, storeAnnotation (Annotation.encode annotation))GotAnnotation annotation ->( { model | annotation = annotation }, Cmd.none)
Our main
function needs a small change to configure the OnPageChange
message. Set the onPageChange
field in the record passed to Pages.Platform.init
to Just OnPageChange
.
Add loadAnnotation
, onFissionAnnotation
, and storeAnnotation
ports.
port loadAnnotation : Json.Decode.Value -> Cmd msgport onFissionAnnotation : (Json.Decode.Value -> msg) -> Sub msgport storeAnnotation : Json.Decode.Value -> Cmd msg
Add a subscription to the onFissionAnnotation
port.
subscriptions : Model -> Sub Msgsubscriptions _ =Sub.batch[ onFissionAuth(\val ->Json.Decode.decodeValue authDecoder val|> Result.toMaybe|> GotAuth), onFissionAnnotation(\val ->Annotation.fromValue val|> GotAnnotation)]
We create an annotation from the Value
received over onFissionAnnotation
and use GotAnnotation
to update our model with it.
Pass the annotation and two messages to tell it how to load and update annotations to Layout.view
.
Layout.view(pageView model siteMetadata page viewForPage)page{ loginMsg = SubmittedLogin, username = model.username }{ annotation = model.annotation, onLoadAnnotation = LoadAnnotation, onUpdateAnnotation = UpdateAnnotation}
In Layout.elm
, the view
displays the annotation at the bottom right corner and in front of the rest of the page. We check if the user is logged in and viewing a blog post. If both are true, we show them the annotation.
view :{ title : String, body : List (Element msg) }->{ path : PagePath Pages.PathKey, frontmatter : Metadata}->{ loginMsg : msg, username : Maybe String}->{ annotation : Annotation, onLoadAnnotation : String -> msg, onUpdateAnnotation : Annotation -> msg}-> { title : String, body : Html msg }view document page fissionAuth annotationOptions ={ title = document.title, body =Element.column[ Element.width Element.fill, Element.height Element.fill-- Maybe show the annotation, Element.inFront <|case fissionAuth.username ofJust username ->case page.frontmatter ofMetadata.Article metadata ->Annotation.view{ annotation = annotationOptions.annotation, title = metadata.title, onLoadAnnotation = annotationOptions.onLoadAnnotation, onUpdateAnnotation = annotationOptions.onUpdateAnnotation}_ ->Element.noneNothing ->Element.none][ header page.path fissionAuth, Element.column[ Element.padding 30, Element.spacing 40, Element.Region.mainContent, Element.width (Element.fill |> Element.maximum 800), Element.centerX]document.body]|> Element.layout[ Element.width Element.fill, Font.size 20, Font.family [ Font.typeface "Roboto" ], Font.color (Element.rgba255 0 0 0 0.8)]}
Our app is ready for annotations and the last step is adding webnative storage.
Each Fission user has a filesystem that is stored locally and distributed across the web. An app that uses Fission storage asks the user for permission to use their filesystem -- similar to how a native mobile app asks for permission. After the app has been granted permission, it can store user data to their local filesystem and publish it to the distributed filesystem.
Let's start by installing the webnative
package from npm.
npm install webnative
Import it in index.js
.
import * as webnative from 'webnative';
We initialize webnative
with a list of permissions stating what our app would like to use. In our case, we only need to request permission to use the storage associated with our app.
Shared storage: The webnative filesystem also has public and private shared storage that can be accessed across apps. See the webnative documentation for more details.
Declare an fs
variable that will be used to access the user's filesystem. Add fissionInit
with a request for permissions
to use app storage by app name and your Fission username.
let fs;const fissionInit = {permissions: {app: {name: 'fission-elm-pages-starter',creator: '<username>'}}};
Replace <username>
with your Fission username.
Next, we initialize webnative
. In the AuthSucceeded
and Continuation
cases, the user has authenticated and granted permission to use the filesystem. We can now set up the filesystem and a way to load and store annotations.
pagesInit({mainElmModule: Elm.Main}).then(app => {webnative.initialize(fissionInit).then(async state => {switch (state.scenario) {case webnative.Scenario.AuthSucceeded:case webnative.Scenario.Continuation:app.ports.onFissionAuth.send({ username: state.username });// [1] Alias the filesystem from statefs = state.fs;// [2] Create the app directory if it does not existconst appPath = fs.appPath();const appDirectoryExists = await fs.exists(appPath);if (!appDirectoryExists) {await fs.mkdir(appPath);await fs.publish();}// [3] Load an annotation or send an empty oneapp.ports.loadAnnotation.subscribe(async ({ title }) => {const path = fs.appPath(['annotations', `${title}.json`]);if (await fs.exists(path)) {const annotation = JSON.parse(await fs.read(path));app.ports.onFissionAnnotation.send({title: annotation.title,notes: annotation.notes});} else {app.ports.onFissionAnnotation.send({title,notes: ''});}});// [4] Store and publish an annotation to the filesystemapp.ports.storeAnnotation.subscribe(async annotation => {if (annotation !== null) {const path = fs.appPath(['annotations',`${annotation.title}.json`]);await transaction(fs.write, path, JSON.stringify(annotation));}});break;case webnative.Scenario.NotAuthorised:case webnative.Scenario.AuthCancelled:break;}app.ports.login.subscribe(() => {webnative.redirectToLobby(state.permissions);});});});
Annotations are stored in app storage in an annotations
subdirectory as JSON. Each annotation is stored as a file using the blog post title and a .json
suffix. For example, an annotation on the blog post "Hello Galaxy 🌠" would be stored on the path annotations/Hello Galaxy 🌠.json
. Paths are case sensitive, spaces are fine, and emoji are welcomed!
Paths are built using fs.appPath
which takes an array of strings that are parts of a path separated by forward slashes.
The numbered references in the code go as follows:
Alias the filesystem from state. We alias state.fs
as fs
to put it onto the global scope.
Create the app directory if it does not exist. A new user will not have a directory for our app. Check if the directory exists and make one if not with fs.mkdir
. Each time we make a change to the local filesystem, we fs.publish
to synchronize with the distributed filesystem.
Load an annotation or send an empty one. When the Elm app sends a message over the loadAnnotation
port, we make check if a file for the annotation exists. If it does, we fs.read
and send it over the onFissionAuth
port into the Elm app. If not, we send an empty annotation value.
Store and publish an annotation to the filesystem. When the Elm app sends a message over the storeAnnotation
port, webnative
saves the annotation to the filesystem with fs.write
. The current implementation uses a transaction queue to handle writes that come in quick succession. See the transaction queue implementation to examine how this works now. An upcoming version of webnative
will handle writes in parallel, and the transaction queue will not be needed.
Everything is in place and users can write their notes about blog posts! Annotations persist across visits to the blog and are available on any device where the user has logged in with Fission.
We haven't covered shared storage in this guide, but you can do much more when user data is not restricted to a single app. With shared private storage, you could write an app that shows users their annotations from multiple apps across the web. With shared public storage, you could convert annotations into a publicly visible comments!
For more information on how Fission storage works, take a look at: