{"id":13879139,"url":"https://github.com/mdominiak/hotwire-chat","last_synced_at":"2025-07-16T15:31:35.510Z","repository":{"id":39008338,"uuid":"329548268","full_name":"mdominiak/hotwire-chat","owner":"mdominiak","description":"Hotwire Chat is a demo Ruby on Rails web application built with Hotwire.","archived":false,"fork":false,"pushed_at":"2022-12-07T08:19:15.000Z","size":6067,"stargazers_count":156,"open_issues_count":21,"forks_count":17,"subscribers_count":3,"default_branch":"master","last_synced_at":"2024-11-24T08:32:40.944Z","etag":null,"topics":["hotwire","rails","ruby","stimulus","turbo"],"latest_commit_sha":null,"homepage":"https://hotwired-chat.herokuapp.com","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mdominiak.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2021-01-14T08:08:59.000Z","updated_at":"2024-11-13T15:43:03.000Z","dependencies_parsed_at":"2023-01-24T11:45:18.968Z","dependency_job_id":null,"html_url":"https://github.com/mdominiak/hotwire-chat","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/mdominiak/hotwire-chat","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mdominiak%2Fhotwire-chat","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mdominiak%2Fhotwire-chat/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mdominiak%2Fhotwire-chat/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mdominiak%2Fhotwire-chat/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mdominiak","download_url":"https://codeload.github.com/mdominiak/hotwire-chat/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mdominiak%2Fhotwire-chat/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":265521426,"owners_count":23781500,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["hotwire","rails","ruby","stimulus","turbo"],"created_at":"2024-08-06T08:02:10.986Z","updated_at":"2025-07-16T15:31:35.051Z","avatar_url":"https://github.com/mdominiak.png","language":"Ruby","funding_links":[],"categories":["Ruby"],"sub_categories":[],"readme":"# Hotwire Chat\n\n[![rspec](https://github.com/mdominiak/hotwire-chat/workflows/rspec/badge.svg)](https://github.com/mdominiak/hotwire-chat/actions)\n\nDemo chat web application built in Ruby on Rails with [Hotwire](https://hotwire.dev).\u003cbr /\u003eThe demo is available at: https://hotwired-chat.herokuapp.com\n\n![Hotwire Chat Demo](public/chat.gif)\n\n[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)\n\n## Table of Contents\n\n* [Creating message](#creating-message)\n  * [Broadcasting created message](#broadcasting-created-message)\n* [Editing message](#editing-message)\n* [Updating message](#updating-message)\n  * [Broadcasting updated message](#broadcasting-updated-message)\n* [Cancelling message edit](#cancelling-message-edit)\n* [Destroying message](#destroying-message)\n  * [Broadcasting destroyed message](#broadcasting-destroyed-message)\n* [Caching](#caching)\n  * [Local time](#local-time)\n* [Testing](#testing)\n  * [Request specs](#request-specs)\n  * [System specs](#system-specs)\n* [Deploying to Heroku](#deploying-to-heroku)\n  \n## Creating message\n\n![create message](/public/messages_create.png)\n\nWhen message form is submitted to the `POST /rooms/1/messages` endpoint, the [messages#create](app/controllers/messages_controller.rb) controller action\n\n```ruby\n# app/controllers/messages_controller.rb\nclass MessagesController \u003c ApplicationController\n  def create\n    @message = @room.messages.new(message_params)\n    @message.author = current_user\n\n    if @message.save\n      render turbo_stream: turbo_stream.append(:messages, @message) # \u003c--\n    else\n      render 'new', layout: false, status: :unprocessable_entity\n    end\n  end\nend\n```\n\nreturns the following response:\n\n```html\n\u003cturbo-frame action=\"append\" target=\"messages\"\u003e\n  \u003cfragment\u003e\n    \u003c!-- app/views/messages/_message.html.erb partial --\u003e\n    \u003cturbo-frame id=\"message_367\" ...\u003e\n      ...\n    \u003c/turbo-frame\u003e\n  \u003c/fragment\u003e\n\u003c/turbo-frame\u003e\n```\n\nwhich 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](https://github.com/hotwired/turbo-rails) gem.\n\n### Broadcasting created message\n\nWhen 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](app/views/rooms/show.html.erb) view rendered by [rooms#show](app/controllers/rooms_controller.rb) action:\n\n```erb\n\u003c!-- app/views/rooms/show.html.erb --\u003e\n\u003c%= turbo_stream_from @room %\u003e\n```\n\nBesides subscription, Turbo will automatically unsubscribe from the channel when navigating away from the room page, for example, when logging out.\n\nAll message changes (create, update, destroy) are asynchronously broadcasted to the message's room channel.\n\n```ruby\n# app/models/message.rb\nclass Message \u003c ApplicationRecord\n  broadcasts_to :room\nend\n```\n\nOn creating a new message in [messages#create](app/controllers/messages_controller.rb) controller action, turbo stream append action is broadcasted to all message's room subscribers:\n\n![create message broadcast](public/messages_create_ws.png)\n\nThe 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.\n\n## Editing message\n\nThe edit link is nested under the message turbo frame:\n\n![message edit link](public/messages_edit_link.png)\n\nWhen a user clicks the link, the `GET /messages/371/edit` [messages#edit](app/controllers/messages_controller.rb) endpoint returns the turbo frame with the matching identifier containing the message form:\n\n```erb\n\u003c!-- app/views/messages/edit.html.erb --\u003e\n\u003c%= turbo_frame_tag dom_id(@message) do %\u003e\n  \u003c%= render 'form', message: @message %\u003e\n\u003c% end %\u003e\n```\n\nOn receiving a response containing turbo frame with matching identifier, Turbo replaces the content of the turbo frame:\n\n![message edit](public/messages_edit.png)\n\nTurbo 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.\n\n## Updating message\n\nThe message edit form is nested under the message turbo frame:\n\n![message edit form](public/messages_edit_form.png)\n\nWhen a user submits the form, the `PATCH /messages/371` [messages#update](app/controllers/messages_controller.rb) endpoint renders the turbo frame with the matching identifier containing the html of the updated message:\n\n```ruby\n# app/controllers/messages_controller.rb\nclass MessagesController \u003c ApplicationController\n  def update\n    if @message.update(message_params)\n      render @message # renders app/views/messages/_message.html.erb partial\n    else\n      render 'edit', layout: false, status: :unprocessable_entity\n    end\n  end\nend\n```\n\n```erb\n\u003c!-- app/views/messages/_message.html.erb --\u003e\n\u003c%= turbo_frame_tag dom_id(message) do %\u003e\n  ...\n\u003c% end %\u003e\n```\n\nOn receiving the response containing turbo frame with the matching identifier, Turbo replaces the content of the turbo frame:\n\n![message update](public/messages_update.png)\n\n### Broadcasting updated message\n\n![message update web socket](public/messages_update_ws.png)\n\n## Cancelling message edit\n\n![cancel message edit link](public/messages_edit_cancel_link.png)\n\n![cancel message edit](public/messages_edit_cancel.png)\n\n## Destroying message\n\n![delete message link](public/messages_delete_link.png)\n\n![delete message](public/messages_delete.png)\n\n### Broadcasting destroyed message\n\n![delete message web socket](public/messages_delete_ws.png)\n\n## Caching\n\nOne 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.\n\nThe demo app renders message content with [html-pipeline](https://github.com/gjtorikian/html-pipeline) transforming raw text with various filters like markdown, sanitization, emoji into `html_safe` formatted content:\n\n```ruby\n# app/services/html_formatter.rb\nclass HtmlFormatter\n  class \u003c\u003c self\n    def call(content)\n      pipeline.call(content)[:output].to_s.html_safe\n    end\n\n    private \n\n      def pipeline\n        @pipeline ||= HTML::Pipeline.new([\n          HTML::Pipeline::MarkdownFilter,\n          HTML::Pipeline::SanitizationFilter,\n          UnicodeEmojiFilter\n        ])\n      end\n  end\nend\n```\n\nThe cost of rendering messages on `GET /rooms/1` page can be optimized by caching the messages:\n```erb\n\u003c!-- app/views/rooms/show.html.erb --\u003e\n\u003c%= render partial: 'messages/message', collection: @messages, cached: true %\u003e\n```\n\nwhich can be observed in the rails log as follows:\n```\nRendered collection of messages/_message.html.erb [4 / 100 cache hits] (Duration: 221.3ms | Allocations: 62880)\n```\n\nand on the subsequent vist:\n\n```\nRendered collection of messages/_message.html.erb [100 / 100 cache hits] (Duration: 23.7ms | Allocations: 6292)\n```\n\n### Local time\n\nThe 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),\n[local_time](https://github.com/basecamp/local_time) gem is used to render the timestamps in UTC on the server-side:\n\n```erb\n\u003c!-- app/views/messages/_message.html.erb --\u003e\n\u003c%= local_time message.created_at, format: :short, class: 'fw-light fs-7' %\u003e\n```\n\n```html\n\u003ctime datetime=\"2021-01-23T18:11:02Z\" data-local=\"time\" data-format=\"%d %b %H:%M\"\u003e23 Jan 18:11\u003c/time\u003e\n```\n\nThe timestamps are then converted with [local-time](https://github.com/basecamp/local_time) javascript libary into local time zone:\n\n```javascript\n// app/javascript/application.js\nimport LocalTime from 'local-time'\nLocalTime.start()\n```\n\n```html\n\u003ctime 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\"\u003e23 Jan 19:11\u003c/time\u003e\n```\n\n## Testing\n\n```\nbin/rspec\n```\n\nSee [.github/workflows/rspec.yml](.github/workflows/rspec.yml) for example configuration of running tests in [GitHub Actions](https://github.com/mdominiak/hotwire-chat/actions).\n\n### Request specs\n\nTurbo 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:\n\n```ruby\n# spec/requets/messages/create_spec.rb\nrequire 'rails_helper'\n\ndescribe 'messages#create', type: :request do\n  let!(:user) { log_in('matt' )}\n  let!(:room) { Room.create!(name: 'dev') }\n\n  let(:message_params) { { content: 'hi!' } }\n  subject { post room_messages_path(room_id: room.id), params: { message: message_params }, headers: turbo_stream_headers }\n\n  it 'returns turbo stream appending message' do\n    subject\n\n    expect(response).to have_http_status(200)\n    assert_select(\"turbo-stream[action='append'][target='messages']\", 1)\n  end\nend\n```\n\nwhere `turbo_stream_headers` is defined as follows:\n\n```ruby\n# spec/support/turbo_stream_spec_support.rb\nmodule TurboStreamSpecSupport\n  def turbo_stream_headers(headers={})\n    headers.merge('Accept': %i[ turbo_stream html ].map{ |type| Mime[type].to_s }.join(', '))\n  end\nend\n```\n\nLink 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:\n\n```ruby\n# spec/requests/messages/edit_spec.rb\nrequire 'rails_helper'\n\ndescribe 'messages#edit', type: :request do\n  let!(:author) { User.create(name: 'matt') }\n  let!(:current_user) { log_in(author.name) }\n  let!(:message) { Message.create!(room: Room.default_room, author: author, content: 'hello') }\n\n  let(:headers) { turbo_stream_headers.merge('Turbo-Frame': \"message_#{message.id}\") }\n  subject { get edit_message_path(message.id), headers: headers }\n\n  it 'returns turbo frame with message form' do\n    subject\n\n    expect(response).to have_http_status(200)\n    assert_select('body', 0)\n    assert_select(\"turbo-frame#message_#{message.id}\", 1)\n    assert_select(\"form[action='#{message_path(message)}']\", 1)\n  end\nend\n```\n\nSee [spec/requests](spec/requests) for more examples.\n\n### System specs\n\nSystem 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](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:\n\n```ruby\n# spec/system/receive_message_spec.rb\nrequire 'rails_helper'\n\ndescribe \"receive message\", type: :system do\n  before do\n    driven_by(:selenium, using: :headless_chrome)\n  end\n\n  let!(:user) { User.create(name: 'matt') }\n\n  it \"shows message\" do\n    log_in(user.name)\n\n    other_user = User.create!(name: 'adam')\n    other_message = Message.create!(room: Room.default_room, author: other_user, content: 'Got it!')\n    within('#messages') do\n      expect(page).to have_content 'Got it!', count: 1\n    end\n  end\nend\n\n```\n\nSee [specs/system](spec/system) directory for more examples.\n\n### Deploying to Heroku\n\nThe 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:\n\n[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)\n\nor manually:\n\n```sh\n  $ heroku create\n  $ heroku addons:create heroku-postgresql\n  $ heroku addons:create heroku-redis\n  $ heroku addons:create rollbar # optional step for error tracking (see config/initializers/rollbar.rb)\n  $ heroku config:set WEB_CONCURRENCY=2 # optional step to configure 2 puma worker processes (see config/puma.rb)\n  $ git push heroku master\n  $ heroku open\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmdominiak%2Fhotwire-chat","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmdominiak%2Fhotwire-chat","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmdominiak%2Fhotwire-chat/lists"}