All Projects → mdominiak → Hotwire Chat

mdominiak / Hotwire Chat

Licence: mit
Hotwire Chat is a demo Ruby on Rails web application built with Hotwire.

Programming Languages

ruby
36898 projects - #4 most used programming language

Labels

Projects that are alternatives of or similar to Hotwire Chat

Messenger Bot Rails
Ruby on Rails Gem for the Facebook Messenger Bot Platform
Stars: ✭ 64 (-12.33%)
Mutual labels:  rails
Elemental components
Simple view components for Rails 5.1+
Stars: ✭ 68 (-6.85%)
Mutual labels:  rails
Activeadmin dynamic fields
ActiveAdmin plugin to add dynamic behaviors to fields
Stars: ✭ 73 (+0%)
Mutual labels:  rails
Sudo rails
🔒 Sudo mode for your Rails controllers
Stars: ✭ 66 (-9.59%)
Mutual labels:  rails
Execution time
How fast is your code? See it directly in Rails console.
Stars: ✭ 67 (-8.22%)
Mutual labels:  rails
Spreadsheet architect
Spreadsheet Architect is a library that allows you to create XLSX, ODS, or CSV spreadsheets super easily from ActiveRecord relations, plain Ruby objects, or tabular data.
Stars: ✭ 1,160 (+1489.04%)
Mutual labels:  rails
Ama
"Ask Me Anything" with Rails 5.2 Application
Stars: ✭ 61 (-16.44%)
Mutual labels:  rails
Codetriage
Discover the best way to get started contributing to Open Source projects
Stars: ✭ 1,185 (+1523.29%)
Mutual labels:  rails
Active attr
What ActiveModel left out
Stars: ✭ 1,155 (+1482.19%)
Mutual labels:  rails
Baremetrics V1
This was the very first version of Baremetrics from 2013. It's published here for posterity.
Stars: ✭ 73 (+0%)
Mutual labels:  rails
Rails 5 api tutorial
Building the Perfect Rails 5 API Only App & Documenting Rails-based REST API using Swagger UI
Stars: ✭ 66 (-9.59%)
Mutual labels:  rails
Ifme
Free, open source mental health communication web app to share experiences with loved ones
Stars: ✭ 1,147 (+1471.23%)
Mutual labels:  rails
Redmine jenkins
A Redmine plugin which makes building your Jenkins projects easy ;)
Stars: ✭ 69 (-5.48%)
Mutual labels:  rails
Daru View
daru-view is for easy and interactive plotting in web application & IRuby notebook. daru-view is a plugin gem to the existing daru gem.
Stars: ✭ 65 (-10.96%)
Mutual labels:  rails
System tester
A Development Tool for creating and managing system tests for Ruby on Rails >= 5.1 Applications
Stars: ✭ 73 (+0%)
Mutual labels:  rails
Pivorak Web App
Rails App for PivorakMeetup
Stars: ✭ 64 (-12.33%)
Mutual labels:  rails
Anycable rails demo
AnyCable Rails demo application and its different variations
Stars: ✭ 68 (-6.85%)
Mutual labels:  rails
Test Prof
Ruby Tests Profiling Toolbox
Stars: ✭ 1,193 (+1534.25%)
Mutual labels:  rails
Active enumerable
ActiveRecord like query methods for Ruby enumerable collections.
Stars: ✭ 73 (+0%)
Mutual labels:  rails
Sparkler
A Rails webapp that gathers and displays system statistics from Sparkle app updater (macOS/app versions etc.)
Stars: ✭ 71 (-2.74%)
Mutual labels:  rails

Hotwire Chat

rspec

Demo chat web application built in Ruby on Rails with Hotwire.
The demo is available at: https://hotwired-chat.herokuapp.com

Hotwire Chat Demo

Deploy

Table of Contents

Creating message

create message

When message form is submitted to the POST /rooms/1/messages endpoint, the messages#create controller action

# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    @message = @room.messages.new(message_params)
    @message.author = current_user

    if @message.save
      render turbo_stream: turbo_stream.append(:messages, @message) # <--
    else
      render 'new', layout: false, status: :unprocessable_entity
    end
  end
end

returns the following response:

<turbo-frame action="append" target="messages">
  <fragment>
    <!-- app/views/messages/_message.html.erb partial -->
    <turbo-frame id="message_367" ...>
      ...
    </turbo-frame>
  </fragment>
</turbo-frame>

which is turbo stream action appending html fragment of newly created message to #messages container element. DOM updates are automatically handled by Turbo javascript on client side. The turbo_stream method used in the controller code is provided by turbo-rails gem.

Broadcasting created message

When visiting a chat room page GET /rooms/1, the client automatically subscribes to the room channel turbo stream via ActionCable web socket. The subscription instruction is included in rooms/show.html.erb view rendered by rooms#show action:

<!-- app/views/rooms/show.html.erb -->
<%= turbo_stream_from @room %>

Besides subscription, Turbo will automatically unsubscribe from the channel when navigating away from the room page, for example, when logging out.

All message changes (create, update, destroy) are asynchronously broadcasted to the message's room channel.

# app/models/message.rb
class Message < ApplicationRecord
  broadcasts_to :room
end

On creating a new message in messages#create controller action, turbo stream append action is broadcasted to all message's room subscribers:

create message broadcast

The broadcasting is not bound to controller actions only. Any call to Message.create, message.update, message.destroy triggering ActiveRecord callbacks will result in corresponding broadcasts. Particularly, it is possible to trigger broadcasts in the rails console.

Editing message

The edit link is nested under the message turbo frame:

message edit link

When a user clicks the link, the GET /messages/371/edit messages#edit endpoint returns the turbo frame with the matching identifier containing the message form:

<!-- app/views/messages/edit.html.erb -->
<%= turbo_frame_tag dom_id(@message) do %>
  <%= render 'form', message: @message %>
<% end %>

On receiving a response containing turbo frame with matching identifier, Turbo replaces the content of the turbo frame:

message edit

Turbo javascript automatically detects navigation within turbo frame and translates it into fetch request to GET /messages/371/edit with extra headers Turbo-Frame: message_371 and Accept: text/vnd.turbo-stream.html, text/html, application/xhtml+xml. On server side, turbo-rails detects Turbo-Frame header and optimizes the response to not render application layout.

Updating message

The message edit form is nested under the message turbo frame:

message edit form

When a user submits the form, the PATCH /messages/371 messages#update endpoint renders the turbo frame with the matching identifier containing the html of the updated message:

# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def update
    if @message.update(message_params)
      render @message # renders app/views/messages/_message.html.erb partial
    else
      render 'edit', layout: false, status: :unprocessable_entity
    end
  end
end
<!-- app/views/messages/_message.html.erb -->
<%= turbo_frame_tag dom_id(message) do %>
  ...
<% end %>

On receiving the response containing turbo frame with the matching identifier, Turbo replaces the content of the turbo frame:

message update

Broadcasting updated message

message update web socket

Cancelling message edit

cancel message edit link

cancel message edit

Destroying message

delete message link

delete message

Broadcasting destroyed message

delete message web socket

Caching

One of the key advantages of building modern web applications with Hotwire is server-side rendered views, which can be efficiently cached to reduce rendering time.

The demo app renders message content with html-pipeline transforming raw text with various filters like markdown, sanitization, emoji into html_safe formatted content:

# app/services/html_formatter.rb
class HtmlFormatter
  class << self
    def call(content)
      pipeline.call(content)[:output].to_s.html_safe
    end

    private 

      def pipeline
        @pipeline ||= HTML::Pipeline.new([
          HTML::Pipeline::MarkdownFilter,
          HTML::Pipeline::SanitizationFilter,
          UnicodeEmojiFilter
        ])
      end
  end
end

The cost of rendering messages on GET /rooms/1 page can be optimized by caching the messages:

<!-- app/views/rooms/show.html.erb -->
<%= render partial: 'messages/message', collection: @messages, cached: true %>

which can be observed in the rails log as follows:

Rendered collection of messages/_message.html.erb [4 / 100 cache hits] (Duration: 221.3ms | Allocations: 62880)

and on the subsequent vist:

Rendered collection of messages/_message.html.erb [100 / 100 cache hits] (Duration: 23.7ms | Allocations: 6292)

Local time

The demo app displays the message's timestamps in local time zone. In order to keep the message partial cache friendly (independent of time zone context), local_time gem is used to render the timestamps in UTC on the server-side:

<!-- app/views/messages/_message.html.erb -->
<%= local_time message.created_at, format: :short, class: 'fw-light fs-7' %>
<time datetime="2021-01-23T18:11:02Z" data-local="time" data-format="%d %b %H:%M">23 Jan 18:11</time>

The timestamps are then converted with local-time javascript libary into local time zone:

// app/javascript/application.js
import LocalTime from 'local-time'
LocalTime.start()
<time datetime="2021-01-23T18:11:02Z" class="fw-light fs-7" data-local="time" data-format="%d %b %H:%M" title="January 23, 2021 at 7:11pm CEST" data-localized="" aria-label="23 Jan 19:11">23 Jan 19:11</time>

Testing

bin/rspec

See .github/workflows/rspec.yml for example configuration of running tests in GitHub Actions.

Request specs

Turbo client-side automatically sets Accept: text/vnd.turbo-stream.html, text/html, application/xhtml+xml header for fetch requests originating from it. The header is recognized by turbo-rails on the server-side, so it is essential to set this header when writing request specs (aka integration tests) to simulate Turbo requests:

# spec/requets/messages/create_spec.rb
require 'rails_helper'

describe 'messages#create', type: :request do
  let!(:user) { log_in('matt' )}
  let!(:room) { Room.create!(name: 'dev') }

  let(:message_params) { { content: 'hi!' } }
  subject { post room_messages_path(room_id: room.id), params: { message: message_params }, headers: turbo_stream_headers }

  it 'returns turbo stream appending message' do
    subject

    expect(response).to have_http_status(200)
    assert_select("turbo-stream[action='append'][target='messages']", 1)
  end
end

where turbo_stream_headers is defined as follows:

# spec/support/turbo_stream_spec_support.rb
module TurboStreamSpecSupport
  def turbo_stream_headers(headers={})
    headers.merge('Accept': %i[ turbo_stream html ].map{ |type| Mime[type].to_s }.join(', '))
  end
end

Link navigations within turbo frame have the extra Turbo-Frame: message_371 header passed by Turbo on the client-side. turbo-rails also recognizes this header to skip rendering of application layout, so corresponding requests specs should resemble it:

# spec/requests/messages/edit_spec.rb
require 'rails_helper'

describe 'messages#edit', type: :request do
  let!(:author) { User.create(name: 'matt') }
  let!(:current_user) { log_in(author.name) }
  let!(:message) { Message.create!(room: Room.default_room, author: author, content: 'hello') }

  let(:headers) { turbo_stream_headers.merge('Turbo-Frame': "message_#{message.id}") }
  subject { get edit_message_path(message.id), headers: headers }

  it 'returns turbo frame with message form' do
    subject

    expect(response).to have_http_status(200)
    assert_select('body', 0)
    assert_select("turbo-frame#message_#{message.id}", 1)
    assert_select("form[action='#{message_path(message)}']", 1)
  end
end

See spec/requests for more examples.

System specs

System specs (aka system tests) are driven_by(:selenium, using: :headless_chrome) and do not require extra configuration for turbo stream actions delivered over web socket. For example spec/system/receive_message_spec.rb tests user receiving turbo stream append action over the action cable web socket when message is sent by other chat participant:

# spec/system/receive_message_spec.rb
require 'rails_helper'

describe "receive message", type: :system do
  before do
    driven_by(:selenium, using: :headless_chrome)
  end

  let!(:user) { User.create(name: 'matt') }

  it "shows message" do
    log_in(user.name)

    other_user = User.create!(name: 'adam')
    other_message = Message.create!(room: Room.default_room, author: other_user, content: 'Got it!')
    within('#messages') do
      expect(page).to have_content 'Got it!', count: 1
    end
  end
end

See specs/system directory for more examples.

Deploying to Heroku

The demo app can be deployed to Heroku for testing on Heroku's limited free tier. The Deploy to Heroku button will provision a free Heroku dyno, heroku-postgresql:free, and heroku-redis:free addons:

Deploy

or manually:

  $ heroku create
  $ heroku addons:create heroku-postgresql
  $ heroku addons:create heroku-redis
  $ heroku addons:create rollbar # optional step for error tracking (see config/initializers/rollbar.rb)
  $ heroku config:set WEB_CONCURRENCY=2 # optional step to configure 2 puma worker processes (see config/puma.rb)
  $ git push heroku master
  $ heroku open
Note that the project description data, including the texts, logos, images, and/or trademarks, for each open source project belongs to its rightful owner. If you wish to add or remove any projects, please contact us at [email protected].