6.S063 Design for the Web: Languages and User Interfaces

↑ All studios

Studio 11

Transition exercise

We will work on making a popup that smoothly appears when you focus a text input above it is focused:

Start by forking this pen.

Step 1: Add a transition

As the code currently stands, the popup appears and disappears instantly. Use the transition property to make it animate smoothly. Note that display is not animatable, so the transition property will do nothing with the current code. You can use a tranform to scale it down to 0, then a transition from that will make it appear to grow. Do not use a very long duration as that can make a UI feel sluggish. As a rule of thumb, avoid durations longer than 400ms (0.4s)

Step 2: Fix the transform to make it grow from its pointer

The popup currently grows from its center, which looks a bit weird. Use the transform-origin property to make it grow from the tip of the pointer instead.

Do not eyeball this; look at the CSS creating the pointer and calculate what the offsets should be. The diagram below may be helpful:

where:

Keep in mind that when specifying a transform origin, that to go higher or to the left of the element, you need to use negative values.

Step 3: Make it bounce!

We currently have a pretty decent transition:

We can go a step further and make it bounce at the end, to make it feel more alive and playful.

Use cubic-bezier.com to create a bezier curve that bounces at the end (i.e. the y value goes beyond 100%, then comes back to 100%). The exact curve is up to you.

Step 4: Make it bounce only when it appears

Some of you will not have this problem, as it depends on how you wrote your CSS. In that case, you have nothing to fix!

Play a little with your transition. Especially if you use an intense bounce, you get a weird artifact when the popup disappears: it shrinks to 0, then grows a little bit on the other side of the screen, then disappears entirely. This is because your timing function is applied on both states: normally when growing, and in reverse when shrinking, so you get a "bounce" when shrinking too!

To fix this, you should only apply your timing function when the popup appears, not when it disappears. If input:not(:focus) + .popup hides our popup, what selector would select it only when it appears?

{# Solution: https://codepen.io/leaverou/pen/JjmJrPV/de6255e3e43a65ecbdbb0f30fbdbea7b #}

Sending Media in Chat

No chat app is complete without the ability to send media including images, video, and audio. This studio is just going to go over sending and receiving images, but the process is generally the same for other types of media.

Step 1: File Input

Add controls to your app so that the user can browse for an image to upload. This means basically adding the following line somewhere in your HTML:

<input type="file" accept="image/*"/>

Step 2: Reacting to File Input

Now you need your app to respond somehow to the file the user uploads. When a user selects a file in an <input type="file"/>, the element emits a change event. You can capture that event in Vue by adding the attribute @change="onImageAttachment" to the <input type="file"/> you declared in step 1.

You also need to actually create the function that @change is calling. Add it to the methods section of your app in chat.js You can access the attached file as follows.

const app = {
  ...

  methods {
  ...

    onImageAttachment(event) {
      const file = event.target.files[0]

      // Do something with the file!
    }

  ...
  }
}

The file is of type File. Try printing it's name property when a user selects an image to attach.

Step 3: Caching the Image

The function above is called as soon as the image is attached. However, you shouldn't actually upload and send it until the user presses "Send Message".

Store the file as a property of the app until it needs to be accessed. This should be really easy, just assign it to a class property:

this.file = file

Step 4: Uploading the Image

Graffiti provides a method $gf.media.store that uploads a file and returns a uri that can be used to fetch it. The $gf.media.store function is asynchronous so you will need to await it's completion before you are given the object's uri. See the end of the vanilla Graffiti docs for an example.

Once the $gf.media.store function resolves, you can add the file's uri (called a magnet URI) to the message you're sending. You can do that with an attachment property.

{
  type: 'Note',
  ...
  attachment: {
    type: 'Image',
    magnet: < the result of `$gf.media.store` >
  }
}

Add this functionality to sendMessage method in the chat.js file. Some things to remember:

To check that this is working, add some code to display the attachment JSON if it exists in the message. Within the v-for loop where messages are displayed in index.html, print the attachment, which you can do as follows:

<div v-if="message.attachment">
  {{ message.attachment }}
</div>

Finally, try downloading the image. The image is uploaded as a WebTorrent. You can download webtorrents manually using Webtorrent Desktop. Paste the Magnet link into Webtorrent Desktop and make sure you receive the image you send.

Note that your app must be open for other people to download the file. WebTorrent is a peer-to-peer protocol, so rather than actually uploading the file to a server your computer acts as a server and other peers download the file directly from your computer. Once they've downloaded the file they become "seeds" and also act as servers for other people to download the file.

This has the benefit that there is no restrictions on how much you upload since you and your peers are the ones responsible for hosting it. However the limitations are:

While these restrictions are not the best for usability, we will not judge you for them since they are inherent to the infrastructure we are providing you. However, the usability of your app can vary a lot based on how and whether you communicate current state to the user, and that is in scope for this assignment.

Step 5: Reacting To Images

Every time you receive an image you need to download and display it. First, let's just make sure your code can do something when it receives an image.

Create a Vue watcher that reacts whenever the messages array changes.

const app = {
  ...

  watch: {
    messages(messages) {
      // Your code here
    }
  }

  ...
}

That is all the messages, but we only care about messages with images. Use Array filtering to filter for messages that:

Filtering objects like this is a common pattern in Graffiti so check out the rest of chat.js of examples on how to do it.

Finally, you only need to react to new images. To do this, you're going to have to save which images you've already downloaded. Initialize this cache in data():

const app= {
  ...
  data() {
    return {
      downloadedImages: {}
    }
  }
  ...
}

Now, back in the messages watch function, create a loop over the filtered array of messages with attachments. Check if the magnet link, message.attachment.magnet, is a key of the cache, this.downloadedImages:

Make sure that that you see your print statement whenever you send or are sent an attachment.

Step 6: Downloading Images

To download an image you can use the asynchronous function $gf.media.fetch, which essentially inverts the upload function $gf.media.store. It takes a magnet link as input and returns a Blob (which is a generic version of File).

Unfortunately Blobs and Files can't be directly passed to <img> tags, the src field only accepts links. You can make a "virtual link" by calling URL.createObjectURL on the object.

Putting this all together, modify your messages watcher so that when you find a new magnet link, rather than simply print and marking true in the cache:

Step 7: Displaying Images

Finally, let's display images in the app!

In step 4, you conditionally created a <div> in the v-for loop of messages if a message has an attachment. Within that, create another conditional that checks if the magnet link of the attachment is also in the cache.

<img :src="downloadedImages[message.attachment.magnet]"/>

Exercise 3: Like Button

Many chat apps allow you to like or react to messages. This feature reduces the friction of responding to simple requests and reduces visual clutter by consolidating many responses into a single number positioned right next to the message each is responding to. Let's make one in your app!

Step 1: Creating the Like Component

To seperate the Like button's code out from the app's existing code, we're going to create a Vue component. A component is kind of like a function but for bits of Vue code. Like a function, the component, is defined once but can be used in various places in your code. Also like a function, it can take inputs that make it do specific things.

The input of the like button will be a message ID, which will indicate which message the like button is for. That is declared with props. Then the template selects for the HTML tag where we're going to store the component's HTML definition. In this case, select for a tag with id="like":

const Like = {
  props: ["messageid"],

  template: '#like'
}

Then in the index.html file, add a template that prints the message ID

<template id="like">
  This is going to be a like button for {{ messageid }}
</template>

To register the component, so it can be used in the app, modify the line allllll the way at the end of chat.js that adds the Name component to also include Like:

app.components = { Name, Like }

Finally, add the like button into the v-for loop of messages and make sure you can see the template code and the printed message ID.

<like :messageid="message.id"></like>

Step 2: Adding a Button

Replace the text and printed message ID in the like component's template with a <button>. This button should have a @click attribute that calls a method in chat.js. This is just like the @change from Step 2 of the Image-Sending exercise.

The method that the button calls will have to be defined in the Like component's method section not the app's method section:

const like = {
  ...
  methods: {
    sendLike() {
      // Your code here
    }
  }
  ...
}

Make the button print this.messageid when it's clicked and test that it works.

Step 3: Sending a Like

Sending a like is similar to sending a message. Likes just require posting a different sort of JSON object, an ActivityPub Like, which has the following template:

{
  type: 'Like',
  object: this.messageid
  context: [this.messageid]
}

Make your button post a like with $gf.post whenever the like button is pressed. See the sendMessage method if you want to see how messages are sent.

Step 4: Fetching Likes

Graffiti allows you to query for all the objects that are in a particular context and then you have to filter for the ones that are relevant: in this case Like objects.

The likes are posted in the context of the message ID they're liking. To get the collection of objects in that context you can add the following:

const like = {
  ...

  setup(props) {
    const $gf = Vue.inject('graffiti')
    const messageid = Vue.toRef(props, 'messageid')
    const { objects: likesRaw } = $gf.useObjects([messageid])
    return { likesRaw }
  }

  ...
}

It's OK if that code remains black magic, it's using the Vue composition API syntax. The most important line is $gf.useObjects([messageid]) where the collection of objects in the context of messageid is declared.

The resulting collection is called likesRaw because the collection may include other objects that are not Like - it will need to be filtered. The variable which can be accessed in other parts of the component using this.likesRaw.

Step 5: Filtering Likes

To filter the objects, you can use a computed property just like the one that is used to filter messages.

const like = {
  ...
  computed: {
    likes() {
      this.likesRaw.filter(
        // Your filtering here
      )
    }
  }
  ...
}

You need to filter for:

It might also be a good idea to remove likes with duplicate actors so that people can't like more than once.

Finally, in the like template include {{ likes.length }}. You should now be able to see the number of likes associated with each message.

Step 6: Unliking

Right now your like button only likes but never unlikes. Use another computed property to filter this.likes for just your likes. These are likes where like.actor == $gf.me

Then in the template make the like button into a dislike button v-if you have already liked the post. In the @click callback of the dislike button use $gf.remove to remove your like(s).