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:
- pl is the pointer left offset
- pw is the pointer width
- ph is the pointer height
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:
- Only try uploading a file if the user has attached one, otherwise simply send a message.
- Once you've sent an image, delete it from the "cache". You don't want to send the same attachment with every message.
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:
- When you close the app, you stop serving/"seeding" files. If no one is seeding a file it can't be downloaded.
- Media transmission can sometimes be slow, especially when you first load a page.
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:
- Have an
attachment
property that is an object. - Have an
attachment.type
property that is equal to"Image"
. - Have an
attachment.magnet
property that is a string.
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
:
- If it is a key, you've already cached it so don't do anything.
- If it is not a key, print something, and then mark it in cache by setting
this.downloadedImages[message.attachment.magnet] = true
. In the next step you'll actually download the images.
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:
- Download the image using
$gf.media.fetch
(useawait
!) - Store the url returned from
URL.createObjectURL
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.
- If the magnet link is not in the cache, display some sort of loading status for the user.
- If the magnet is in the cache, display it using an
<img>
tag:
<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:
- Objects that have a
type
property equal to"Like"
. - Objects that have an
object
property equal tothis.messageid
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).