Resources

Download Source Code

Summary

# Terminal
rails g model comment name content:rich_text
rails g controller comments index
rails g controller comments/polls show
rails g stimulus poller

# config/routes.rb
Rails.application.routes.draw do
  resources :comments, only: [:index, :create]
  namespace :comments do
    resource :poll, only: :show
  end
  root to: 'comments#index'
  get "up" => "rails/health#show", as: :rails_health_check
end

# app/controllers/comments/polls_controller.rb
class Comments::PollsController < ApplicationController
  def show
    last_seen_id = params[:last_seen_id].to_i
    @comments = Comment.with_rich_text_content_and_embeds.where("id > ?", last_seen_id)
                       .order(id: :desc)
    render partial: "comments/comment", collection: @comments, layout: false
  end
end

# app/javascript/controllers/poller_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="poller"
export default class extends Controller {
  static targets = ["list"]
  static values = {
    url: String,
    lastSeenId: Number,
    interval: { type: Number, default: 3000 }
  }

  connect() {
    this.timer = setInterval(() => this.poll(), this.intervalValue)
  }

  disconnect() {
    clearInterval(this.timer)
  }

  async poll() {
    const response = await fetch(`${this.urlValue}?last_seen_id=${this.lastSeenIdValue}`)
    const html = await response.text()
    if (html.trim() === "") return

    this.listTarget.insertAdjacentHTML("afterbegin", html)
    console.log(`poll id: ${Number(this.listTarget.firstElementChild.dataset.pollId)}`)
    this.lastSeenIdValue = Number(this.listTarget.firstElementChild.dataset.pollId)
  }
}

# app/views/comments/_comment.html.erb
<%= tag.article id: dom_id(comment), "data-poll-id": comment.id,
         class: "flex gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:shadow-md" do %>
  <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-100 font-semibold text-blue-700">
    <%= comment.name.to_s.first&.upcase %>
  </div>

  <div class="min-w-0 flex-1">
    <div class="flex items-baseline justify-between gap-2">
      <span class="font-semibold text-slate-900"><%= comment.name %></span>
      <time class="shrink-0 text-xs text-slate-400"><%= comment.created_at.strftime("%b %-d, %-l:%M %p") %></time>
    </div>
    <div class="mt-1 text-slate-700">
      <%= comment.content %>
    </div>
  </div>
<% end %>

# app/views/comments/index.html.erb
<div
  data-controller="poller"
  data-poller-url-value="<%= comments_poll_path %>"
  data-poller-last-seen-id-value="<%= @comments.first&.id.to_i %>"
  data-poller-interval-value="3000">

  <div data-poller-target="list" exclass="space-y-3">
    <%= render @comments %>
  </div>

  <% if @comments.empty? %>
    <p
      class="
        rounded-xl border border-dashed border-slate-300 bg-white p-8
        text-center text-slate-400
      "
    >
      No comments yet. Be the first to post one.
    </p>
  <% end %>
</div>