<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.superails.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.superails.com/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-03-03T12:33:16+00:00</updated><id>https://blog.superails.com/feed.xml</id><title type="html">SupeRails Blog</title><subtitle>My Ruby on Rails dev log. Tips and tricks that will save you time.</subtitle><author><name>Yaroslav Shmarov</name><email>hello@superails.com</email></author><entry><title type="html">Devise has_many :sessions - track, list, and revoke active sessions</title><link href="https://blog.superails.com/devise-multiple-sessions-warden-hooks" rel="alternate" type="text/html" title="Devise has_many :sessions - track, list, and revoke active sessions" /><published>2026-03-03T00:00:00+00:00</published><updated>2026-03-03T00:00:00+00:00</updated><id>https://blog.superails.com/devise-multiple-sessions-warden-hooks</id><content type="html" xml:base="https://blog.superails.com/devise-multiple-sessions-warden-hooks"><![CDATA[<p>Track every browser session for a Devise-authenticated user, display them in “your active sessions” UI, and let users revoke sessions remotely. When a session is revoked, the next request from that browser forces a sign-out.</p>

<p>This approach hooks into Warden (the authentication layer underneath Devise) so it works with <strong>all</strong> sign-in methods: standard email/password, magic links, OmniAuth, <code class="language-plaintext highlighter-rouge">sign_in(user)</code> — everything.</p>

<h2 id="architecture-overview">Architecture overview</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sign in → Warden fires :set_user → SessionManager.on_sign_in → Session.track!
Request → Warden fires :fetch    → SessionManager.on_fetch   → touch_last_active! / force sign-out if revoked
Sign out → Warden fires logout   → SessionManager.on_logout  → Session#revoke!
</code></pre></div></div>

<p>Every browser gets a UUID stored in the encrypted session cookie. That UUID maps to a <code class="language-plaintext highlighter-rouge">Session</code> database record. On each request, Warden’s <code class="language-plaintext highlighter-rouge">:fetch</code> event checks the record — if it’s been revoked (from another device), the user is force-signed-out.</p>

<h2 id="1-migration">1. Migration</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/20260301000000_create_sessions.rb</span>
<span class="k">class</span> <span class="nc">CreateSessions</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">7.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">create_table</span> <span class="ss">:sessions</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:session_id</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:ip_address</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:user_agent</span><span class="p">,</span> <span class="ss">limit: </span><span class="mi">1024</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:browser_name</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:os_name</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:device_type</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">datetime</span> <span class="ss">:last_active_at</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">datetime</span> <span class="ss">:revoked_at</span>

      <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
    <span class="k">end</span>

    <span class="n">add_index</span> <span class="ss">:sessions</span><span class="p">,</span> <span class="ss">:session_id</span><span class="p">,</span> <span class="ss">unique: </span><span class="kp">true</span>
    <span class="n">add_index</span> <span class="ss">:sessions</span><span class="p">,</span> <span class="p">[</span><span class="ss">:user_id</span><span class="p">,</span> <span class="ss">:revoked_at</span><span class="p">]</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Key details:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">session_id</code> has a <strong>unique index</strong> — this is critical for the race condition handling in <code class="language-plaintext highlighter-rouge">track!</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">revoked_at</code> is a soft-delete timestamp. We don’t hard-delete sessions because we need them for the “active sessions” UI and for detecting revoked cookies.</li>
  <li><code class="language-plaintext highlighter-rouge">user_agent</code> is capped at 1024 characters because user agents can be absurdly long.</li>
</ul>

<p>Run the migration:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:migrate
</code></pre></div></div>

<h2 id="2-session-model">2. Session model</h2>

<p>Add the <code class="language-plaintext highlighter-rouge">browser</code> gem for user-agent parsing:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"browser"</span><span class="p">,</span> <span class="s2">"~&gt; 6.0"</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">install</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/session.rb</span>
<span class="k">class</span> <span class="nc">Session</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:user</span>

  <span class="n">scope</span> <span class="ss">:active</span><span class="p">,</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">where</span><span class="p">(</span><span class="ss">revoked_at: </span><span class="kp">nil</span><span class="p">)</span> <span class="p">}</span>

  <span class="n">before_create</span> <span class="ss">:parse_user_agent</span>

  <span class="c1"># Find-or-create a session record for a given session_id.</span>
  <span class="c1"># Handles race conditions from concurrent requests.</span>
  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">track!</span><span class="p">(</span><span class="n">user</span><span class="p">:,</span> <span class="n">session_id</span><span class="p">:,</span> <span class="n">request</span><span class="p">:)</span>
    <span class="n">find_or_create_by!</span><span class="p">(</span><span class="ss">session_id: </span><span class="n">session_id</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">session</span><span class="o">|</span>
      <span class="n">session</span><span class="p">.</span><span class="nf">user</span> <span class="o">=</span> <span class="n">user</span>
      <span class="n">session</span><span class="p">.</span><span class="nf">ip_address</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="nf">remote_ip</span>
      <span class="n">session</span><span class="p">.</span><span class="nf">user_agent</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="nf">user_agent</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">first</span><span class="p">(</span><span class="mi">1024</span><span class="p">)</span>
      <span class="n">session</span><span class="p">.</span><span class="nf">last_active_at</span> <span class="o">=</span> <span class="no">Time</span><span class="p">.</span><span class="nf">current</span>
    <span class="k">end</span>
  <span class="k">rescue</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">RecordNotUnique</span>
    <span class="n">retries</span> <span class="o">||=</span> <span class="mi">0</span>
    <span class="k">retry</span> <span class="k">if</span> <span class="p">(</span><span class="n">retries</span> <span class="o">+=</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">3</span>
    <span class="k">raise</span>
  <span class="k">end</span>

  <span class="c1"># Soft-revoke the session. Does NOT delete the record.</span>
  <span class="k">def</span> <span class="nf">revoke!</span>
    <span class="n">update!</span><span class="p">(</span><span class="ss">revoked_at: </span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="c1"># Update last_active_at, but only if 5+ minutes have passed.</span>
  <span class="c1"># Avoids a DB write on every single request.</span>
  <span class="k">def</span> <span class="nf">touch_last_active!</span>
    <span class="k">return</span> <span class="k">if</span> <span class="n">last_active_at</span> <span class="o">&gt;</span> <span class="mi">5</span><span class="p">.</span><span class="nf">minutes</span><span class="p">.</span><span class="nf">ago</span>

    <span class="n">update_column</span><span class="p">(</span><span class="ss">:last_active_at</span><span class="p">,</span> <span class="no">Time</span><span class="p">.</span><span class="nf">current</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">parse_user_agent</span>
    <span class="k">return</span> <span class="k">if</span> <span class="n">user_agent</span><span class="p">.</span><span class="nf">blank?</span>

    <span class="n">client</span> <span class="o">=</span> <span class="no">Browser</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">user_agent</span><span class="p">)</span>
    <span class="nb">self</span><span class="p">.</span><span class="nf">browser_name</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="nf">name</span>
    <span class="nb">self</span><span class="p">.</span><span class="nf">os_name</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="nf">platform</span><span class="p">.</span><span class="nf">name</span>
    <span class="nb">self</span><span class="p">.</span><span class="nf">device_type</span> <span class="o">=</span> <span class="k">if</span> <span class="n">client</span><span class="p">.</span><span class="nf">device</span><span class="p">.</span><span class="nf">mobile?</span>
      <span class="s2">"Mobile"</span>
    <span class="k">elsif</span> <span class="n">client</span><span class="p">.</span><span class="nf">device</span><span class="p">.</span><span class="nf">tablet?</span>
      <span class="s2">"Tablet"</span>
    <span class="k">else</span>
      <span class="s2">"Desktop"</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Why <code class="language-plaintext highlighter-rouge">find_or_create_by!</code> + <code class="language-plaintext highlighter-rouge">rescue RecordNotUnique</code>? Because <code class="language-plaintext highlighter-rouge">find_or_create_by!</code> is not atomic. Two concurrent requests can both fail the SELECT, then both attempt INSERT. The unique index on <code class="language-plaintext highlighter-rouge">session_id</code> causes one to fail. We rescue and retry — the second attempt will find the existing record.</p>

<h2 id="3-user-model-association">3. User model association</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/user.rb</span>
<span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">devise</span> <span class="ss">:database_authenticatable</span><span class="p">,</span> <span class="ss">:registerable</span><span class="p">,</span> <span class="ss">:recoverable</span><span class="p">,</span> <span class="ss">:rememberable</span><span class="p">,</span> <span class="ss">:validatable</span>

  <span class="n">has_many</span> <span class="ss">:sessions</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="4-sessionmanager">4. SessionManager</h2>

<p>This module contains the three lifecycle methods called from Warden hooks. Keeping them in a separate module makes them independently testable.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/lib/session_manager.rb</span>
<span class="k">module</span> <span class="nn">SessionManager</span>
  <span class="c1"># Called on sign-in (Warden :set_user or :authentication events).</span>
  <span class="c1"># Generates a session_id UUID and creates the Session record.</span>
  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">on_sign_in</span><span class="p">(</span><span class="n">user</span><span class="p">:,</span> <span class="n">warden</span><span class="p">:,</span> <span class="n">scope</span><span class="p">:)</span>
    <span class="n">session_id</span> <span class="o">=</span> <span class="n">warden</span><span class="p">.</span><span class="nf">session</span><span class="p">(</span><span class="n">scope</span><span class="p">)[</span><span class="s2">"session_id"</span><span class="p">]</span> <span class="o">||=</span> <span class="no">SecureRandom</span><span class="p">.</span><span class="nf">uuid</span>
    <span class="no">Session</span><span class="p">.</span><span class="nf">track!</span><span class="p">(</span>
      <span class="ss">user: </span><span class="n">user</span><span class="p">,</span>
      <span class="ss">session_id: </span><span class="n">session_id</span><span class="p">,</span>
      <span class="ss">request: </span><span class="no">ActionDispatch</span><span class="o">::</span><span class="no">Request</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">warden</span><span class="p">.</span><span class="nf">env</span><span class="p">)</span>
    <span class="p">)</span>
  <span class="k">rescue</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">ActiveRecordError</span> <span class="o">=&gt;</span> <span class="n">e</span>
    <span class="no">Rails</span><span class="p">.</span><span class="nf">logger</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="s2">"[SessionManager] on_sign_in failed: </span><span class="si">#{</span><span class="n">e</span><span class="p">.</span><span class="nf">message</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="c1"># Called on every authenticated request (Warden :fetch event).</span>
  <span class="c1"># Checks for revocation, updates last_active_at, or lazy-creates a missing record.</span>
  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">on_fetch</span><span class="p">(</span><span class="n">user</span><span class="p">:,</span> <span class="n">warden</span><span class="p">:,</span> <span class="n">scope</span><span class="p">:)</span>
    <span class="n">session_id</span> <span class="o">=</span> <span class="n">warden</span><span class="p">.</span><span class="nf">session</span><span class="p">(</span><span class="n">scope</span><span class="p">)[</span><span class="s2">"session_id"</span><span class="p">]</span>
    <span class="k">return</span> <span class="k">unless</span> <span class="n">session_id</span>

    <span class="n">session_record</span> <span class="o">=</span> <span class="no">Session</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">session_id: </span><span class="n">session_id</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">session_record</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">revoked_at?</span>
      <span class="n">force_sign_out</span><span class="p">(</span><span class="ss">warden: </span><span class="n">warden</span><span class="p">,</span> <span class="ss">scope: </span><span class="n">scope</span><span class="p">)</span>
    <span class="k">elsif</span> <span class="n">session_record</span>
      <span class="n">session_record</span><span class="p">.</span><span class="nf">touch_last_active!</span>
    <span class="k">else</span>
      <span class="c1"># Lazy-create: session_id exists in cookie but DB record is missing</span>
      <span class="no">Session</span><span class="p">.</span><span class="nf">track!</span><span class="p">(</span>
        <span class="ss">user: </span><span class="n">user</span><span class="p">,</span>
        <span class="ss">session_id: </span><span class="n">session_id</span><span class="p">,</span>
        <span class="ss">request: </span><span class="no">ActionDispatch</span><span class="o">::</span><span class="no">Request</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">warden</span><span class="p">.</span><span class="nf">env</span><span class="p">)</span>
      <span class="p">)</span>
    <span class="k">end</span>
  <span class="k">rescue</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">ActiveRecordError</span> <span class="o">=&gt;</span> <span class="n">e</span>
    <span class="no">Rails</span><span class="p">.</span><span class="nf">logger</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="s2">"[SessionManager] on_fetch failed: </span><span class="si">#{</span><span class="n">e</span><span class="p">.</span><span class="nf">message</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="c1"># Called on sign-out (Warden before_logout hook).</span>
  <span class="c1"># Soft-revokes the session record.</span>
  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">on_logout</span><span class="p">(</span><span class="n">warden</span><span class="p">:,</span> <span class="n">scope</span><span class="p">:)</span>
    <span class="c1"># Skip if this logout was triggered by our own force_sign_out</span>
    <span class="c1"># (the session is already revoked in that case)</span>
    <span class="k">return</span> <span class="k">if</span> <span class="n">warden</span><span class="p">.</span><span class="nf">env</span><span class="p">[</span><span class="s2">"app.revoking_session"</span><span class="p">]</span>

    <span class="c1"># IMPORTANT: Access the raw Rack session directly. Do NOT call</span>
    <span class="c1"># warden.session(scope) here — see "The on_logout footgun" below.</span>
    <span class="n">scoped_session</span> <span class="o">=</span> <span class="n">warden</span><span class="p">.</span><span class="nf">raw_session</span><span class="p">[</span><span class="s2">"warden.user.</span><span class="si">#{</span><span class="n">scope</span><span class="si">}</span><span class="s2">.session"</span><span class="p">]</span>
    <span class="n">session_id</span> <span class="o">=</span> <span class="n">scoped_session</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="s2">"session_id"</span><span class="p">)</span>
    <span class="k">return</span> <span class="k">unless</span> <span class="n">session_id</span>

    <span class="n">session_record</span> <span class="o">=</span> <span class="no">Session</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">session_id: </span><span class="n">session_id</span><span class="p">)</span>
    <span class="n">session_record</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">revoke!</span> <span class="k">unless</span> <span class="n">session_record</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">revoked_at?</span>
  <span class="k">rescue</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">ActiveRecordError</span> <span class="o">=&gt;</span> <span class="n">e</span>
    <span class="no">Rails</span><span class="p">.</span><span class="nf">logger</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="s2">"[SessionManager] on_logout failed: </span><span class="si">#{</span><span class="n">e</span><span class="p">.</span><span class="nf">message</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="c1"># Force sign-out for a revoked session. Sets a flag to prevent</span>
  <span class="c1"># the before_logout hook from double-revoking.</span>
  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">force_sign_out</span><span class="p">(</span><span class="n">warden</span><span class="p">:,</span> <span class="n">scope</span><span class="p">:)</span>
    <span class="n">warden</span><span class="p">.</span><span class="nf">env</span><span class="p">[</span><span class="s2">"app.revoking_session"</span><span class="p">]</span> <span class="o">=</span> <span class="kp">true</span>
    <span class="n">proxy</span> <span class="o">=</span> <span class="no">Devise</span><span class="o">::</span><span class="no">Hooks</span><span class="o">::</span><span class="no">Proxy</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">warden</span><span class="p">)</span>
    <span class="n">proxy</span><span class="p">.</span><span class="nf">sign_out</span><span class="p">(</span><span class="n">scope</span><span class="p">)</span>
    <span class="kp">throw</span> <span class="ss">:warden</span><span class="p">,</span> <span class="ss">scope: </span><span class="n">scope</span><span class="p">,</span> <span class="ss">message: :revoked_session</span>
  <span class="k">end</span>
  <span class="nb">private_class_method</span> <span class="ss">:force_sign_out</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="important-actiondispatchrequest-vs-rackrequest">Important: <code class="language-plaintext highlighter-rouge">ActionDispatch::Request</code> vs <code class="language-plaintext highlighter-rouge">Rack::Request</code></h3>

<p>Warden hooks receive a <code class="language-plaintext highlighter-rouge">Warden::Proxy</code> (<code class="language-plaintext highlighter-rouge">warden</code>). <code class="language-plaintext highlighter-rouge">warden.request</code> returns a <strong><code class="language-plaintext highlighter-rouge">Rack::Request</code></strong>, which does NOT have the <code class="language-plaintext highlighter-rouge">remote_ip</code> method. <code class="language-plaintext highlighter-rouge">remote_ip</code> is an <code class="language-plaintext highlighter-rouge">ActionDispatch::Request</code> method. You must wrap the env:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># CORRECT — ActionDispatch::Request has #remote_ip</span>
<span class="n">request</span> <span class="o">=</span> <span class="no">ActionDispatch</span><span class="o">::</span><span class="no">Request</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">warden</span><span class="p">.</span><span class="nf">env</span><span class="p">)</span>
<span class="n">request</span><span class="p">.</span><span class="nf">remote_ip</span> <span class="c1"># =&gt; "192.168.1.1"</span>

<span class="c1"># WRONG — Rack::Request does NOT have #remote_ip</span>
<span class="n">warden</span><span class="p">.</span><span class="nf">request</span><span class="p">.</span><span class="nf">remote_ip</span> <span class="c1"># =&gt; NoMethodError</span>
</code></pre></div></div>

<h3 id="the-on_logout-footgun-never-call-wardensessionscope-in-before_logout">The <code class="language-plaintext highlighter-rouge">on_logout</code> footgun: never call <code class="language-plaintext highlighter-rouge">warden.session(scope)</code> in <code class="language-plaintext highlighter-rouge">before_logout</code></h3>

<p>This is the single most important gotcha. In <code class="language-plaintext highlighter-rouge">on_logout</code>, you <strong>must</strong> read the session_id from <code class="language-plaintext highlighter-rouge">warden.raw_session</code>, not <code class="language-plaintext highlighter-rouge">warden.session(scope)</code>. Here’s why:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">warden.session(scope)</code> internally calls <code class="language-plaintext highlighter-rouge">authenticated?(scope)</code> → <code class="language-plaintext highlighter-rouge">user(scope)</code>.</li>
  <li>During <code class="language-plaintext highlighter-rouge">before_logout</code>, Warden has already cleared <code class="language-plaintext highlighter-rouge">@users[scope]</code> from memory.</li>
  <li><code class="language-plaintext highlighter-rouge">user(scope)</code> sees <code class="language-plaintext highlighter-rouge">@users[scope]</code> is nil, re-fetches the user from the session serializer, and <strong>re-populates <code class="language-plaintext highlighter-rouge">@users[scope]</code></strong>.</li>
  <li>When <code class="language-plaintext highlighter-rouge">sign_in</code> is called later in the same request (e.g. a magic-link flow does <code class="language-plaintext highlighter-rouge">sign_out(current_user)</code> then <code class="language-plaintext highlighter-rouge">sign_in(new_user)</code>), Warden sees the user already in <code class="language-plaintext highlighter-rouge">@users[scope]</code> and <strong>skips session storage</strong>.</li>
  <li>The next request finds no user in the session cookie → authentication fails.</li>
</ol>

<p>This bug is particularly insidious because it only manifests when sign-out is immediately followed by sign-in <strong>in the same request</strong> — a pattern common in magic-link and token-based authentication flows.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># CORRECT — reads raw Rack session, no Warden side effects</span>
<span class="n">scoped_session</span> <span class="o">=</span> <span class="n">warden</span><span class="p">.</span><span class="nf">raw_session</span><span class="p">[</span><span class="s2">"warden.user.</span><span class="si">#{</span><span class="n">scope</span><span class="si">}</span><span class="s2">.session"</span><span class="p">]</span>
<span class="n">session_id</span> <span class="o">=</span> <span class="n">scoped_session</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="s2">"session_id"</span><span class="p">)</span>

<span class="c1"># WRONG — re-populates @users[scope], breaks subsequent sign_in</span>
<span class="n">session_id</span> <span class="o">=</span> <span class="n">warden</span><span class="p">.</span><span class="nf">session</span><span class="p">(</span><span class="n">scope</span><span class="p">)</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="s2">"session_id"</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="the-apprevoking_session-flag">The <code class="language-plaintext highlighter-rouge">app.revoking_session</code> flag</h3>

<p>When a revoked session is detected on <code class="language-plaintext highlighter-rouge">:fetch</code>, we call <code class="language-plaintext highlighter-rouge">proxy.sign_out(scope)</code> which triggers the <code class="language-plaintext highlighter-rouge">before_logout</code> hook. Without the flag, <code class="language-plaintext highlighter-rouge">on_logout</code> would try to <code class="language-plaintext highlighter-rouge">revoke!</code> a session that’s already revoked. The flag skips that:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>:fetch detects revoked session
  → sets warden.env["app.revoking_session"] = true
  → calls proxy.sign_out(:user)
    → before_logout hook fires
      → on_logout checks flag → skips revoke (already done)
  → throw :warden redirects to sign-in
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">on_logout</code> method also has an <code class="language-plaintext highlighter-rouge">unless session_record&amp;.revoked_at?</code> guard as a second line of defense — if the flag mechanism fails for any reason, it still won’t double-revoke.</p>

<h2 id="5-warden-hooks-initializer">5. Warden hooks initializer</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/warden_hooks.rb</span>
<span class="no">Warden</span><span class="o">::</span><span class="no">Manager</span><span class="p">.</span><span class="nf">after_set_user</span> <span class="k">do</span> <span class="o">|</span><span class="n">user</span><span class="p">,</span> <span class="n">warden</span><span class="p">,</span> <span class="n">opts</span><span class="o">|</span>
  <span class="n">scope</span> <span class="o">=</span> <span class="n">opts</span><span class="p">[</span><span class="ss">:scope</span><span class="p">]</span>

  <span class="c1"># Sign-in events: create a session record</span>
  <span class="k">if</span> <span class="n">scope</span> <span class="o">==</span> <span class="ss">:user</span> <span class="o">&amp;&amp;</span> <span class="p">[</span><span class="ss">:set_user</span><span class="p">,</span> <span class="ss">:authentication</span><span class="p">].</span><span class="nf">include?</span><span class="p">(</span><span class="n">opts</span><span class="p">[</span><span class="ss">:event</span><span class="p">])</span>
    <span class="no">SessionManager</span><span class="p">.</span><span class="nf">on_sign_in</span><span class="p">(</span><span class="ss">user: </span><span class="n">user</span><span class="p">,</span> <span class="ss">warden: </span><span class="n">warden</span><span class="p">,</span> <span class="ss">scope: </span><span class="n">scope</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="c1"># Fetch events (every authenticated request): check revocation, update activity</span>
  <span class="k">if</span> <span class="n">scope</span> <span class="o">==</span> <span class="ss">:user</span> <span class="o">&amp;&amp;</span> <span class="n">opts</span><span class="p">[</span><span class="ss">:event</span><span class="p">]</span> <span class="o">==</span> <span class="ss">:fetch</span>
    <span class="no">SessionManager</span><span class="p">.</span><span class="nf">on_fetch</span><span class="p">(</span><span class="ss">user: </span><span class="n">user</span><span class="p">,</span> <span class="ss">warden: </span><span class="n">warden</span><span class="p">,</span> <span class="ss">scope: </span><span class="n">scope</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="no">Warden</span><span class="o">::</span><span class="no">Manager</span><span class="p">.</span><span class="nf">before_logout</span> <span class="k">do</span> <span class="o">|</span><span class="n">_user</span><span class="p">,</span> <span class="n">warden</span><span class="p">,</span> <span class="n">opts</span><span class="o">|</span>
  <span class="k">if</span> <span class="n">opts</span><span class="p">[</span><span class="ss">:scope</span><span class="p">]</span> <span class="o">==</span> <span class="ss">:user</span>
    <span class="no">SessionManager</span><span class="p">.</span><span class="nf">on_logout</span><span class="p">(</span><span class="ss">warden: </span><span class="n">warden</span><span class="p">,</span> <span class="ss">scope: </span><span class="n">opts</span><span class="p">[</span><span class="ss">:scope</span><span class="p">])</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="warden-events-reference">Warden events reference</h3>

<table>
  <thead>
    <tr>
      <th>Event</th>
      <th>When</th>
      <th>Our hook</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">:set_user</code></td>
      <td><code class="language-plaintext highlighter-rouge">Devise.sign_in(user)</code> or <code class="language-plaintext highlighter-rouge">warden.set_user(user)</code></td>
      <td>Creates <code class="language-plaintext highlighter-rouge">Session</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">:authentication</code></td>
      <td><code class="language-plaintext highlighter-rouge">warden.authenticate!</code> (e.g. magic link strategies)</td>
      <td>Creates <code class="language-plaintext highlighter-rouge">Session</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">:fetch</code></td>
      <td>Every request where Warden loads user from session cookie</td>
      <td><code class="language-plaintext highlighter-rouge">touch_last_active!</code>, lazy-create, or force sign-out</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">before_logout</code></td>
      <td><code class="language-plaintext highlighter-rouge">warden.logout(:user)</code> on any sign-out</td>
      <td>Soft-revokes <code class="language-plaintext highlighter-rouge">Session</code></td>
    </tr>
  </tbody>
</table>

<h3 id="graceful-degradation-for-existing-sessions">Graceful degradation for existing sessions</h3>

<p>Users who were signed in <strong>before</strong> this feature is deployed have no <code class="language-plaintext highlighter-rouge">session_id</code> in their session cookie. The <code class="language-plaintext highlighter-rouge">:fetch</code> hook guards on <code class="language-plaintext highlighter-rouge">return unless session_id</code> — when it’s <code class="language-plaintext highlighter-rouge">nil</code>, all tracking is skipped. These users browse normally. They’ll get a <code class="language-plaintext highlighter-rouge">Session</code> record on their next sign-in.</p>

<h2 id="6-revoked-session-devise-message">6. Revoked session Devise message</h2>

<p>Add a flash message for the revoked session redirect:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/locales/en/devise.en.yml</span>
<span class="na">en</span><span class="pi">:</span>
  <span class="na">devise</span><span class="pi">:</span>
    <span class="na">failure</span><span class="pi">:</span>
      <span class="na">revoked_session</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Your</span><span class="nv"> </span><span class="s">session</span><span class="nv"> </span><span class="s">has</span><span class="nv"> </span><span class="s">been</span><span class="nv"> </span><span class="s">revoked.</span><span class="nv"> </span><span class="s">Please</span><span class="nv"> </span><span class="s">sign</span><span class="nv"> </span><span class="s">in</span><span class="nv"> </span><span class="s">again."</span>
</code></pre></div></div>

<h2 id="7-sessions-controller-optional--for-manage-sessions-ui">7. Sessions controller (optional — for “manage sessions” UI)</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/sessions_controller.rb</span>
<span class="k">class</span> <span class="nc">SessionsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">before_action</span> <span class="ss">:authenticate_user!</span>

  <span class="k">def</span> <span class="nf">index</span>
    <span class="vi">@sessions</span> <span class="o">=</span> <span class="n">current_user</span><span class="p">.</span><span class="nf">sessions</span><span class="p">.</span><span class="nf">active</span><span class="p">.</span><span class="nf">order</span><span class="p">(</span><span class="ss">last_active_at: :desc</span><span class="p">)</span>
    <span class="vi">@current_session_id</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="nf">env</span><span class="p">[</span><span class="s2">"warden"</span><span class="p">].</span><span class="nf">session</span><span class="p">(</span><span class="ss">:user</span><span class="p">)[</span><span class="s2">"session_id"</span><span class="p">]</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">destroy</span>
    <span class="n">session_record</span> <span class="o">=</span> <span class="n">current_user</span><span class="p">.</span><span class="nf">sessions</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:id</span><span class="p">])</span>
    <span class="n">session_record</span><span class="p">.</span><span class="nf">revoke!</span>
    <span class="n">redirect_to</span> <span class="n">sessions_path</span><span class="p">,</span> <span class="ss">notice: </span><span class="s2">"Session revoked."</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/sessions/index.html.erb %&gt;</span>
<span class="nt">&lt;h1&gt;</span>Your Active Sessions<span class="nt">&lt;/h1&gt;</span>

<span class="cp">&lt;%</span> <span class="vi">@sessions</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">session</span><span class="o">|</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">style=</span><span class="s">"border: 1px solid #ddd; padding: 1rem; margin-bottom: 1rem;"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;p&gt;</span>
      <span class="nt">&lt;strong&gt;</span><span class="cp">&lt;%=</span> <span class="n">session</span><span class="p">.</span><span class="nf">browser_name</span> <span class="cp">%&gt;</span><span class="nt">&lt;/strong&gt;</span> on <span class="cp">&lt;%=</span> <span class="n">session</span><span class="p">.</span><span class="nf">os_name</span> <span class="cp">%&gt;</span>
      (<span class="cp">&lt;%=</span> <span class="n">session</span><span class="p">.</span><span class="nf">device_type</span> <span class="cp">%&gt;</span>)
      <span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">session</span><span class="p">.</span><span class="nf">session_id</span> <span class="o">==</span> <span class="vi">@current_session_id</span> <span class="cp">%&gt;</span>
        <span class="nt">&lt;span</span> <span class="na">style=</span><span class="s">"color: green;"</span><span class="nt">&gt;</span>(This device)<span class="nt">&lt;/span&gt;</span>
      <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/p&gt;</span>
    <span class="nt">&lt;p&gt;</span>IP: <span class="cp">&lt;%=</span> <span class="n">session</span><span class="p">.</span><span class="nf">ip_address</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
    <span class="nt">&lt;p&gt;</span>Last active: <span class="cp">&lt;%=</span> <span class="n">time_ago_in_words</span><span class="p">(</span><span class="n">session</span><span class="p">.</span><span class="nf">last_active_at</span><span class="p">)</span> <span class="cp">%&gt;</span> ago<span class="nt">&lt;/p&gt;</span>

    <span class="cp">&lt;%</span> <span class="k">unless</span> <span class="n">session</span><span class="p">.</span><span class="nf">session_id</span> <span class="o">==</span> <span class="vi">@current_session_id</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">button_to</span> <span class="s2">"Revoke"</span><span class="p">,</span> <span class="n">session_path</span><span class="p">(</span><span class="n">session</span><span class="p">),</span> <span class="ss">method: :delete</span> <span class="cp">%&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resources</span> <span class="ss">:sessions</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:index</span><span class="p">,</span> <span class="ss">:destroy</span><span class="p">]</span>
</code></pre></div></div>

<p>When a user clicks “Revoke” on a session, that session’s <code class="language-plaintext highlighter-rouge">revoked_at</code> is set. The next time the revoked browser makes a request, the <code class="language-plaintext highlighter-rouge">:fetch</code> hook detects it and force-signs it out.</p>

<h2 id="8-tests">8. Tests</h2>

<h3 id="factory">Factory</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># test/factories/sessions.rb</span>
<span class="no">FactoryBot</span><span class="p">.</span><span class="nf">define</span> <span class="k">do</span>
  <span class="n">factory</span> <span class="ss">:session</span> <span class="k">do</span>
    <span class="n">association</span> <span class="ss">:user</span>
    <span class="n">session_id</span> <span class="p">{</span> <span class="no">SecureRandom</span><span class="p">.</span><span class="nf">uuid</span> <span class="p">}</span>
    <span class="n">ip_address</span> <span class="p">{</span> <span class="s2">"127.0.0.1"</span> <span class="p">}</span>
    <span class="n">user_agent</span> <span class="p">{</span> <span class="s2">"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"</span> <span class="p">}</span>
    <span class="n">browser_name</span> <span class="p">{</span> <span class="s2">"Chrome"</span> <span class="p">}</span>
    <span class="n">os_name</span> <span class="p">{</span> <span class="s2">"macOS"</span> <span class="p">}</span>
    <span class="n">device_type</span> <span class="p">{</span> <span class="s2">"Desktop"</span> <span class="p">}</span>
    <span class="n">last_active_at</span> <span class="p">{</span> <span class="no">Time</span><span class="p">.</span><span class="nf">current</span> <span class="p">}</span>

    <span class="n">trait</span> <span class="ss">:mobile</span> <span class="k">do</span>
      <span class="n">user_agent</span> <span class="p">{</span> <span class="s2">"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"</span> <span class="p">}</span>
      <span class="n">browser_name</span> <span class="p">{</span> <span class="s2">"Safari"</span> <span class="p">}</span>
      <span class="n">os_name</span> <span class="p">{</span> <span class="s2">"iOS"</span> <span class="p">}</span>
      <span class="n">device_type</span> <span class="p">{</span> <span class="s2">"Mobile"</span> <span class="p">}</span>
    <span class="k">end</span>

    <span class="n">trait</span> <span class="ss">:revoked</span> <span class="k">do</span>
      <span class="n">revoked_at</span> <span class="p">{</span> <span class="no">Time</span><span class="p">.</span><span class="nf">current</span> <span class="p">}</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="model-tests">Model tests</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># test/models/session_test.rb</span>
<span class="nb">require</span> <span class="s2">"test_helper"</span>

<span class="k">class</span> <span class="nc">SessionTest</span> <span class="o">&lt;</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">TestCase</span>
  <span class="n">setup</span> <span class="k">do</span>
    <span class="vi">@user</span> <span class="o">=</span> <span class="n">create</span><span class="p">(</span><span class="ss">:user</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">".track! creates a new session record"</span> <span class="k">do</span>
    <span class="n">request</span> <span class="o">=</span> <span class="no">OpenStruct</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">remote_ip: </span><span class="s2">"192.168.1.1"</span><span class="p">,</span> <span class="ss">user_agent: </span><span class="s2">"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"</span><span class="p">)</span>
    <span class="n">session_id</span> <span class="o">=</span> <span class="no">SecureRandom</span><span class="p">.</span><span class="nf">uuid</span>

    <span class="n">assert_difference</span> <span class="s2">"Session.count"</span><span class="p">,</span> <span class="mi">1</span> <span class="k">do</span>
      <span class="n">session</span> <span class="o">=</span> <span class="no">Session</span><span class="p">.</span><span class="nf">track!</span><span class="p">(</span><span class="ss">user: </span><span class="vi">@user</span><span class="p">,</span> <span class="ss">session_id: </span><span class="n">session_id</span><span class="p">,</span> <span class="ss">request: </span><span class="n">request</span><span class="p">)</span>
      <span class="n">assert_equal</span> <span class="vi">@user</span><span class="p">,</span> <span class="n">session</span><span class="p">.</span><span class="nf">user</span>
      <span class="n">assert_equal</span> <span class="n">session_id</span><span class="p">,</span> <span class="n">session</span><span class="p">.</span><span class="nf">session_id</span>
      <span class="n">assert_equal</span> <span class="s2">"192.168.1.1"</span><span class="p">,</span> <span class="n">session</span><span class="p">.</span><span class="nf">ip_address</span>
      <span class="n">assert_not_nil</span> <span class="n">session</span><span class="p">.</span><span class="nf">last_active_at</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">".track! does not duplicate sessions with the same session_id"</span> <span class="k">do</span>
    <span class="n">request</span> <span class="o">=</span> <span class="no">OpenStruct</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">remote_ip: </span><span class="s2">"192.168.1.1"</span><span class="p">,</span> <span class="ss">user_agent: </span><span class="s2">"Mozilla/5.0"</span><span class="p">)</span>
    <span class="n">session_id</span> <span class="o">=</span> <span class="no">SecureRandom</span><span class="p">.</span><span class="nf">uuid</span>

    <span class="no">Session</span><span class="p">.</span><span class="nf">track!</span><span class="p">(</span><span class="ss">user: </span><span class="vi">@user</span><span class="p">,</span> <span class="ss">session_id: </span><span class="n">session_id</span><span class="p">,</span> <span class="ss">request: </span><span class="n">request</span><span class="p">)</span>

    <span class="n">assert_no_difference</span> <span class="s2">"Session.count"</span> <span class="k">do</span>
      <span class="no">Session</span><span class="p">.</span><span class="nf">track!</span><span class="p">(</span><span class="ss">user: </span><span class="vi">@user</span><span class="p">,</span> <span class="ss">session_id: </span><span class="n">session_id</span><span class="p">,</span> <span class="ss">request: </span><span class="n">request</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">".track! parses user agent on create"</span> <span class="k">do</span>
    <span class="n">request</span> <span class="o">=</span> <span class="no">OpenStruct</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span>
      <span class="ss">remote_ip: </span><span class="s2">"10.0.0.1"</span><span class="p">,</span>
      <span class="ss">user_agent: </span><span class="s2">"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"</span>
    <span class="p">)</span>

    <span class="n">session</span> <span class="o">=</span> <span class="no">Session</span><span class="p">.</span><span class="nf">track!</span><span class="p">(</span><span class="ss">user: </span><span class="vi">@user</span><span class="p">,</span> <span class="ss">session_id: </span><span class="no">SecureRandom</span><span class="p">.</span><span class="nf">uuid</span><span class="p">,</span> <span class="ss">request: </span><span class="n">request</span><span class="p">)</span>

    <span class="n">assert_equal</span> <span class="s2">"Safari"</span><span class="p">,</span> <span class="n">session</span><span class="p">.</span><span class="nf">browser_name</span>
    <span class="n">assert_includes</span> <span class="n">session</span><span class="p">.</span><span class="nf">os_name</span><span class="p">,</span> <span class="s2">"iOS"</span>
    <span class="n">assert_equal</span> <span class="s2">"Mobile"</span><span class="p">,</span> <span class="n">session</span><span class="p">.</span><span class="nf">device_type</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"#revoke! sets revoked_at timestamp"</span> <span class="k">do</span>
    <span class="n">session</span> <span class="o">=</span> <span class="n">create</span><span class="p">(</span><span class="ss">:session</span><span class="p">,</span> <span class="ss">user: </span><span class="vi">@user</span><span class="p">)</span>
    <span class="n">assert_nil</span> <span class="n">session</span><span class="p">.</span><span class="nf">revoked_at</span>

    <span class="n">session</span><span class="p">.</span><span class="nf">revoke!</span>

    <span class="n">assert_not_nil</span> <span class="n">session</span><span class="p">.</span><span class="nf">reload</span><span class="p">.</span><span class="nf">revoked_at</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"#touch_last_active! updates when stale (&gt;5 minutes)"</span> <span class="k">do</span>
    <span class="n">session</span> <span class="o">=</span> <span class="n">create</span><span class="p">(</span><span class="ss">:session</span><span class="p">,</span> <span class="ss">user: </span><span class="vi">@user</span><span class="p">,</span> <span class="ss">last_active_at: </span><span class="mi">10</span><span class="p">.</span><span class="nf">minutes</span><span class="p">.</span><span class="nf">ago</span><span class="p">)</span>
    <span class="n">original_time</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">last_active_at</span>

    <span class="n">session</span><span class="p">.</span><span class="nf">touch_last_active!</span>

    <span class="n">assert</span> <span class="n">session</span><span class="p">.</span><span class="nf">reload</span><span class="p">.</span><span class="nf">last_active_at</span> <span class="o">&gt;</span> <span class="n">original_time</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"#touch_last_active! does NOT update when recent (&lt;5 minutes)"</span> <span class="k">do</span>
    <span class="n">session</span> <span class="o">=</span> <span class="n">create</span><span class="p">(</span><span class="ss">:session</span><span class="p">,</span> <span class="ss">user: </span><span class="vi">@user</span><span class="p">,</span> <span class="ss">last_active_at: </span><span class="mi">2</span><span class="p">.</span><span class="nf">minutes</span><span class="p">.</span><span class="nf">ago</span><span class="p">)</span>
    <span class="n">original_time</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">last_active_at</span>

    <span class="n">session</span><span class="p">.</span><span class="nf">touch_last_active!</span>

    <span class="n">assert_equal</span> <span class="n">original_time</span><span class="p">,</span> <span class="n">session</span><span class="p">.</span><span class="nf">reload</span><span class="p">.</span><span class="nf">last_active_at</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">".active scope returns only non-revoked sessions"</span> <span class="k">do</span>
    <span class="n">active</span> <span class="o">=</span> <span class="n">create</span><span class="p">(</span><span class="ss">:session</span><span class="p">,</span> <span class="ss">user: </span><span class="vi">@user</span><span class="p">)</span>
    <span class="n">_revoked</span> <span class="o">=</span> <span class="n">create</span><span class="p">(</span><span class="ss">:session</span><span class="p">,</span> <span class="ss">:revoked</span><span class="p">,</span> <span class="ss">user: </span><span class="vi">@user</span><span class="p">)</span>

    <span class="n">results</span> <span class="o">=</span> <span class="vi">@user</span><span class="p">.</span><span class="nf">sessions</span><span class="p">.</span><span class="nf">active</span>
    <span class="n">assert_includes</span> <span class="n">results</span><span class="p">,</span> <span class="n">active</span>
    <span class="n">assert_equal</span> <span class="mi">1</span><span class="p">,</span> <span class="n">results</span><span class="p">.</span><span class="nf">count</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="integration-tests-for-warden-hooks">Integration tests for Warden hooks</h3>

<p>These tests verify the full sign-in/sign-out lifecycle end-to-end:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># test/integration/warden_hooks_test.rb</span>
<span class="nb">require</span> <span class="s2">"test_helper"</span>

<span class="k">class</span> <span class="nc">WardenHooksTest</span> <span class="o">&lt;</span> <span class="no">ActionDispatch</span><span class="o">::</span><span class="no">IntegrationTest</span>
  <span class="kp">include</span> <span class="no">Devise</span><span class="o">::</span><span class="no">Test</span><span class="o">::</span><span class="no">IntegrationHelpers</span>

  <span class="n">setup</span> <span class="k">do</span>
    <span class="vi">@user</span> <span class="o">=</span> <span class="n">create</span><span class="p">(</span><span class="ss">:user</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"sign-in creates a Session record"</span> <span class="k">do</span>
    <span class="n">assert_difference</span> <span class="s2">"Session.count"</span><span class="p">,</span> <span class="mi">1</span> <span class="k">do</span>
      <span class="n">sign_in</span> <span class="vi">@user</span>
      <span class="n">get</span> <span class="n">root_path</span>
    <span class="k">end</span>

    <span class="n">session_record</span> <span class="o">=</span> <span class="vi">@user</span><span class="p">.</span><span class="nf">sessions</span><span class="p">.</span><span class="nf">last</span>
    <span class="n">assert_not_nil</span> <span class="n">session_record</span>
    <span class="n">assert_nil</span> <span class="n">session_record</span><span class="p">.</span><span class="nf">revoked_at</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"multiple requests do not create duplicate sessions"</span> <span class="k">do</span>
    <span class="n">sign_in</span> <span class="vi">@user</span>
    <span class="n">get</span> <span class="n">root_path</span>

    <span class="n">assert_no_difference</span> <span class="s2">"Session.count"</span> <span class="k">do</span>
      <span class="n">get</span> <span class="n">root_path</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"sign-out soft-revokes the session (sets revoked_at, does NOT delete)"</span> <span class="k">do</span>
    <span class="n">sign_in</span> <span class="vi">@user</span>
    <span class="n">get</span> <span class="n">root_path</span>
    <span class="n">session_record</span> <span class="o">=</span> <span class="vi">@user</span><span class="p">.</span><span class="nf">sessions</span><span class="p">.</span><span class="nf">last</span>

    <span class="n">assert_nil</span> <span class="n">session_record</span><span class="p">.</span><span class="nf">revoked_at</span>

    <span class="n">delete</span> <span class="n">destroy_user_session_path</span>

    <span class="n">assert_not_nil</span> <span class="n">session_record</span><span class="p">.</span><span class="nf">reload</span><span class="p">.</span><span class="nf">revoked_at</span>
    <span class="c1"># Record still exists (soft delete)</span>
    <span class="n">assert_equal</span> <span class="mi">1</span><span class="p">,</span> <span class="no">Session</span><span class="p">.</span><span class="nf">count</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"revoked session forces sign-out on next request"</span> <span class="k">do</span>
    <span class="n">sign_in</span> <span class="vi">@user</span>
    <span class="n">get</span> <span class="n">root_path</span>

    <span class="c1"># Simulate revoking from another device</span>
    <span class="vi">@user</span><span class="p">.</span><span class="nf">sessions</span><span class="p">.</span><span class="nf">last</span><span class="p">.</span><span class="nf">revoke!</span>

    <span class="c1"># Next request detects revocation and force-signs-out</span>
    <span class="n">get</span> <span class="n">root_path</span>

    <span class="c1"># Subsequent request redirects to sign-in</span>
    <span class="n">get</span> <span class="n">root_path</span>
    <span class="n">assert_redirected_to</span> <span class="n">new_user_session_path</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"touch_last_active! updates on fetch when stale"</span> <span class="k">do</span>
    <span class="n">sign_in</span> <span class="vi">@user</span>
    <span class="n">get</span> <span class="n">root_path</span>

    <span class="n">session_record</span> <span class="o">=</span> <span class="vi">@user</span><span class="p">.</span><span class="nf">sessions</span><span class="p">.</span><span class="nf">last</span>
    <span class="n">session_record</span><span class="p">.</span><span class="nf">update_column</span><span class="p">(</span><span class="ss">:last_active_at</span><span class="p">,</span> <span class="mi">10</span><span class="p">.</span><span class="nf">minutes</span><span class="p">.</span><span class="nf">ago</span><span class="p">)</span>
    <span class="n">old_time</span> <span class="o">=</span> <span class="n">session_record</span><span class="p">.</span><span class="nf">last_active_at</span>

    <span class="n">get</span> <span class="n">root_path</span>

    <span class="n">assert</span> <span class="n">session_record</span><span class="p">.</span><span class="nf">reload</span><span class="p">.</span><span class="nf">last_active_at</span> <span class="o">&gt;</span> <span class="n">old_time</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"lazy-creates a Session when session_id exists but DB record is missing"</span> <span class="k">do</span>
    <span class="n">sign_in</span> <span class="vi">@user</span>
    <span class="n">get</span> <span class="n">root_path</span>

    <span class="c1"># Delete the record but keep the session_id in the cookie</span>
    <span class="vi">@user</span><span class="p">.</span><span class="nf">sessions</span><span class="p">.</span><span class="nf">delete_all</span>

    <span class="n">assert_difference</span> <span class="s2">"Session.count"</span><span class="p">,</span> <span class="mi">1</span> <span class="k">do</span>
      <span class="n">get</span> <span class="n">root_path</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="9-gotchas-and-decisions">9. Gotchas and decisions</h2>

<h3 id="why-soft-delete-instead-of-hard-delete">Why soft-delete instead of hard-delete?</h3>

<p>Two reasons:</p>
<ol>
  <li><strong>Session history UI</strong> — users can see past sessions even after sign-out.</li>
  <li><strong>Revocation detection</strong> — if a revoked cookie is still active in some browser, the <code class="language-plaintext highlighter-rouge">:fetch</code> hook needs the record to exist (with <code class="language-plaintext highlighter-rouge">revoked_at</code> set) to detect and force sign-out. If we hard-deleted, the hook would see “no record” and <strong>lazy-create a new one</strong>, re-authenticating the revoked session.</li>
</ol>

<h3 id="why-update_column-in-touch_last_active">Why <code class="language-plaintext highlighter-rouge">update_column</code> in <code class="language-plaintext highlighter-rouge">touch_last_active!</code>?</h3>

<p><code class="language-plaintext highlighter-rouge">update_column</code> skips validations and callbacks. For a simple timestamp update on every request, this avoids unnecessary overhead.</p>

<h3 id="why-find_or_create_by-instead-of-create-with-rescue-recordnotfound">Why <code class="language-plaintext highlighter-rouge">find_or_create_by!</code> instead of <code class="language-plaintext highlighter-rouge">create!</code> with <code class="language-plaintext highlighter-rouge">rescue RecordNotFound</code>?</h3>

<p><code class="language-plaintext highlighter-rouge">find_or_create_by!</code> handles the common case (record already exists) with a fast SELECT. The <code class="language-plaintext highlighter-rouge">RecordNotUnique</code> rescue only fires during the race condition window — not on every request.</p>

<h3 id="why-rescue-activerecorderror-in-sessionmanager">Why rescue <code class="language-plaintext highlighter-rouge">ActiveRecordError</code> in SessionManager?</h3>

<p>Session tracking is a <strong>non-critical feature</strong>. If the database is temporarily unavailable or a constraint fails, the user should still be able to sign in and browse. The rescue prevents session tracking failures from breaking authentication. In production, you’d send these to your error tracker (Sentry, Honeybadger, etc.) instead of just logging.</p>

<h3 id="why-scope-on-user-in-the-hooks">Why scope on <code class="language-plaintext highlighter-rouge">:user</code> in the hooks?</h3>

<p>If your app uses multiple Devise scopes (e.g., <code class="language-plaintext highlighter-rouge">:user</code> and <code class="language-plaintext highlighter-rouge">:admin</code>), you must scope the hooks to the correct model. Replace <code class="language-plaintext highlighter-rouge">:user</code> with your scope. If you want session tracking for multiple scopes, duplicate the hooks for each.</p>

<h3 id="the-bypass_sign_in-edge-case">The <code class="language-plaintext highlighter-rouge">bypass_sign_in</code> edge case</h3>

<p>Some apps sign in users programmatically with <code class="language-plaintext highlighter-rouge">bypass_sign_in</code> or <code class="language-plaintext highlighter-rouge">warden.set_user(user, run_callbacks: false)</code>. These skip Warden hooks entirely. If you have such a path, you must call <code class="language-plaintext highlighter-rouge">Session.track!</code> manually:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">sign_in_programmatically</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
  <span class="n">bypass_sign_in</span><span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="ss">scope: :user</span><span class="p">)</span>
  <span class="n">warden</span><span class="p">.</span><span class="nf">set_user</span><span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="ss">scope: :user</span><span class="p">,</span> <span class="ss">run_callbacks: </span><span class="kp">false</span><span class="p">)</span>

  <span class="c1"># Hooks are skipped, so manually track the session:</span>
  <span class="n">session_id</span> <span class="o">=</span> <span class="n">warden</span><span class="p">.</span><span class="nf">session</span><span class="p">(</span><span class="ss">:user</span><span class="p">)[</span><span class="s2">"session_id"</span><span class="p">]</span> <span class="o">||=</span> <span class="no">SecureRandom</span><span class="p">.</span><span class="nf">uuid</span>
  <span class="no">Session</span><span class="p">.</span><span class="nf">track!</span><span class="p">(</span><span class="ss">user: </span><span class="n">user</span><span class="p">,</span> <span class="ss">session_id: </span><span class="n">session_id</span><span class="p">,</span> <span class="ss">request: </span><span class="n">request</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="summary">Summary</h2>

<p>The full implementation is 3 files of application code (model, manager, initializer) plus a migration. Warden’s hook system makes this work transparently with every Devise sign-in strategy. The key design choices:</p>

<ul>
  <li><strong>UUID per browser</strong> stored in the encrypted session cookie</li>
  <li><strong>Soft-delete</strong> via <code class="language-plaintext highlighter-rouge">revoked_at</code> for revocation detection</li>
  <li><strong>5-minute write throttle</strong> on <code class="language-plaintext highlighter-rouge">last_active_at</code> to avoid DB write amplification</li>
  <li><strong>Race condition handling</strong> via unique index + retry</li>
  <li><strong>Graceful degradation</strong> for sessions that predate the feature</li>
  <li><strong><code class="language-plaintext highlighter-rouge">raw_session</code> in <code class="language-plaintext highlighter-rouge">on_logout</code></strong> — never call <code class="language-plaintext highlighter-rouge">warden.session(scope)</code> inside <code class="language-plaintext highlighter-rouge">before_logout</code> hooks; it re-populates Warden’s internal user cache and breaks sign-out/sign-in flows</li>
</ul>]]></content><author><name>Yaroslav Shmarov</name></author><category term="ruby-on-rails" /><category term="devise" /><category term="warden" /><category term="authentication" /><category term="sessions" /><category term="security" /><summary type="html"><![CDATA[Track every browser session for a Devise-authenticated user, display them in “your active sessions” UI, and let users revoke sessions remotely. When a session is revoked, the next request from that browser forces a sign-out.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">OAuth in Hotwire Native iOS apps with ASWebAuthenticationSession</title><link href="https://blog.superails.com/hotwire-native-oauth" rel="alternate" type="text/html" title="OAuth in Hotwire Native iOS apps with ASWebAuthenticationSession" /><published>2026-02-28T00:00:00+00:00</published><updated>2026-02-28T00:00:00+00:00</updated><id>https://blog.superails.com/hotwire-native-oauth</id><content type="html" xml:base="https://blog.superails.com/hotwire-native-oauth"><![CDATA[<p>Google and Apple block OAuth from embedded web views (WKWebView) with <code class="language-plaintext highlighter-rouge">disallowed_useragent</code>. This guide shows how to make OAuth work in a Hotwire Native iOS app using <code class="language-plaintext highlighter-rouge">ASWebAuthenticationSession</code> – Apple’s purpose-built API for OAuth – with a path configuration rule and token handoff.</p>

<p>The approach uses zero bridge components. It works with any OmniAuth provider (Google, Apple, Facebook, etc.) and extends to social account linking (YouTube, TikTok, etc.) where the user is already signed in.</p>

<h2 id="how-it-works">How it works</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WKWebView          ASWebAuthenticationSession       Rails           Provider
    │                          │                      │                │
 1. User taps                  │                      │                │
    "Sign in with Google"      │                      │                │
    │                          │                      │                │
 2. Turbo navigates to         │                      │                │
    /auth/native/google        │                      │                │
    │                          │                      │                │
 3. Path config matches        │                      │                │
    ^/auth/ → "safari"         │                      │                │
    TabBarController rejects   │                      │                │
    proposal, starts           │                      │                │
    ASWebAuthSession ─────────►│                      │                │
    │                          │                      │                │
    │                     4. Loads GET                 │                │
    │                        /auth/native/google ────►│                │
    │                          │                      │                │
    │                     5. Trampoline page           │                │
    │                        auto-submits POST        │                │
    │                        /auth/google_oauth2 ────►│                │
    │                          │                      │                │
    │                          │                 6. OmniAuth            │
    │                          │                    redirects ────────►│
    │                          │                      │                │
    │                          │                      │     7. User    │
    │                          │                      │     signs in   │
    │                          │                      │                │
    │                          │                 8. Callback ◄─────────┤
    │                          │                    /auth/google/      │
    │                          │                    callback           │
    │                          │                      │                │
    │                          │                 9. Detect native      │
    │                          │                    request, generate  │
    │                          │                    token, redirect    │
    │                          │  ◄── yourapp://callback?token=TOKEN   │
    │                          │                      │                │
    │                    10. Completion handler        │                │
    │                        fires with callback URL  │                │
    │  ◄── navigate to        │                      │                │
    │      /hotwire_native/    │                      │                │
    │      sign_in?token=TOKEN │                      │                │
    │                          │                      │                │
 11. Rails validates token, ─────────────────────────►│                │
     signs in user,                                   │                │
     sets session cookie                              │                │
     in WKWebView,                                    │                │
     redirects to /reset_app                          │                │
</code></pre></div></div>

<p>Key insight: <code class="language-plaintext highlighter-rouge">ASWebAuthenticationSession</code> has its own cookie store separate from WKWebView. The user authenticates in the system browser, Rails generates a short-lived token, redirects to a custom URL scheme, and the iOS app hands that token to WKWebView to establish the session.</p>

<h2 id="rails-changes">Rails changes</h2>

<h3 id="1-token-generator-on-user-model">1. Token generator on User model</h3>

<p>Rails 7.1+ has built-in single-use, purpose-scoped tokens:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/user.rb</span>
<span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">generates_token_for</span> <span class="ss">:native_oauth</span><span class="p">,</span> <span class="ss">expires_in: </span><span class="mi">2</span><span class="p">.</span><span class="nf">minutes</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="2-trampoline-controller">2. Trampoline controller</h3>

<p>OmniAuth requires a POST request (CSRF protection). <code class="language-plaintext highlighter-rouge">ASWebAuthenticationSession</code> can only open GET URLs. The trampoline bridges this gap – it’s a GET page that auto-submits a POST form.</p>

<p>The controller also sets backup cookies for the native detection and user token. These are <code class="language-plaintext highlighter-rouge">SameSite=None</code> because Apple Sign In uses <code class="language-plaintext highlighter-rouge">response_mode=form_post</code> – a cross-origin POST that drops <code class="language-plaintext highlighter-rouge">SameSite=Lax</code> session cookies.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/hotwire_native/oauth_controller.rb</span>
<span class="k">class</span> <span class="nc">HotwireNative::OauthController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">skip_before_action</span> <span class="ss">:authenticate_user!</span>
  <span class="n">layout</span> <span class="kp">false</span>

  <span class="k">def</span> <span class="nf">start</span>
    <span class="n">session</span><span class="p">[</span><span class="ss">:native_oauth</span><span class="p">]</span> <span class="o">=</span> <span class="kp">true</span>
    <span class="n">cookies</span><span class="p">[</span><span class="ss">:native_oauth</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">value: </span><span class="s2">"1"</span><span class="p">,</span> <span class="ss">expires: </span><span class="mi">5</span><span class="p">.</span><span class="nf">minutes</span><span class="p">.</span><span class="nf">from_now</span><span class="p">,</span>
      <span class="ss">same_site: :none</span><span class="p">,</span> <span class="ss">secure: </span><span class="kp">true</span>
    <span class="p">}</span>
    <span class="vi">@provider</span> <span class="o">=</span> <span class="n">params</span><span class="p">[</span><span class="ss">:provider</span><span class="p">]</span>
    <span class="vi">@user_token</span> <span class="o">=</span> <span class="n">params</span><span class="p">[</span><span class="ss">:user_token</span><span class="p">]</span>
    <span class="k">return</span> <span class="k">if</span> <span class="vi">@user_token</span><span class="p">.</span><span class="nf">blank?</span>

    <span class="c1"># Signed cookie backup for social linking — survives cross-origin POST</span>
    <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:native_oauth_user_token</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">value: </span><span class="vi">@user_token</span><span class="p">,</span> <span class="ss">expires: </span><span class="mi">5</span><span class="p">.</span><span class="nf">minutes</span><span class="p">.</span><span class="nf">from_now</span><span class="p">,</span>
      <span class="ss">same_site: :none</span><span class="p">,</span> <span class="ss">secure: </span><span class="kp">true</span>
    <span class="p">}</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- app/views/hotwire_native/oauth/start.html.erb --&gt;</span>
<span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html&gt;</span>
<span class="nt">&lt;body&gt;</span>
  <span class="nt">&lt;p&gt;</span>Redirecting...<span class="nt">&lt;/p&gt;</span>
  <span class="cp">&lt;%%</span><span class="o">=</span> <span class="n">form_tag</span> <span class="s2">"/auth/</span><span class="si">#{</span><span class="vi">@provider</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span> <span class="ss">method: :post</span><span class="p">,</span> <span class="ss">id: </span><span class="s2">"oauth-trampoline"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">name=</span><span class="s">"native"</span> <span class="na">value=</span><span class="s">"ios"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%%</span> <span class="k">if</span> <span class="vi">@user_token</span><span class="p">.</span><span class="nf">present?</span> <span class="cp">%&gt;</span>
      <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">name=</span><span class="s">"user_token"</span> <span class="na">value=</span><span class="s">"</span><span class="cp">&lt;%%</span><span class="o">=</span> <span class="vi">@user_token</span> <span class="cp">%&gt;</span><span class="s">"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;noscript&gt;&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span><span class="nt">&gt;</span>Continue to sign in<span class="nt">&lt;/button&gt;&lt;/noscript&gt;</span>
  <span class="cp">&lt;%%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%%</span><span class="o">=</span> <span class="n">javascript_tag</span> <span class="ss">nonce: </span><span class="kp">true</span> <span class="k">do</span> <span class="cp">%&gt;</span>
    document.getElementById("oauth-trampoline").submit();
  <span class="cp">&lt;%%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<h3 id="3-token-sign-in-controller">3. Token sign-in controller</h3>

<p>Validates the token inside WKWebView and establishes the session:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/hotwire_native/sign_in_controller.rb</span>
<span class="k">class</span> <span class="nc">HotwireNative::SignInController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">skip_before_action</span> <span class="ss">:authenticate_user!</span>
  <span class="n">skip_before_action</span> <span class="ss">:require_onboarding</span>

  <span class="k">def</span> <span class="nf">show</span>
    <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by_token_for</span><span class="p">(</span><span class="ss">:native_oauth</span><span class="p">,</span> <span class="n">params</span><span class="p">[</span><span class="ss">:token</span><span class="p">])</span>

    <span class="k">unless</span> <span class="n">user</span>
      <span class="n">redirect_to</span> <span class="n">new_user_session_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="no">I18n</span><span class="p">.</span><span class="nf">t</span><span class="p">(</span><span class="s2">"devise.omniauth_callbacks.failure"</span><span class="p">)</span>
      <span class="k">return</span>
    <span class="k">end</span>

    <span class="n">sign_in</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
    <span class="n">redirect_to</span> <span class="s2">"/reset_app"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">skip_before_action :require_onboarding</code> is essential – without it, a new user would be redirected to onboarding before the session is established, breaking the token handoff.</p>

<h3 id="4-routes">4. Routes</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes/hotwire_native.rb</span>
<span class="n">namespace</span> <span class="ss">:hotwire_native</span> <span class="k">do</span>
  <span class="n">get</span> <span class="s2">"sign_in"</span><span class="p">,</span> <span class="ss">to: </span><span class="s2">"sign_in#show"</span>
<span class="k">end</span>

<span class="c1"># Outside the namespace — the URL must start with /auth/ for the path config rule</span>
<span class="n">get</span> <span class="s2">"auth/native/:provider"</span><span class="p">,</span> <span class="ss">to: </span><span class="s2">"hotwire_native/oauth#start"</span><span class="p">,</span> <span class="ss">as: :native_oauth_start</span>
</code></pre></div></div>

<h3 id="5-omniauth-callback--native-branch">5. OmniAuth callback – native branch</h3>

<p>In your OmniAuth callback controller, detect native requests and redirect to the custom URL scheme instead of signing in directly:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">NATIVE_URL_SCHEME</span> <span class="o">=</span> <span class="s2">"yourapp"</span> <span class="c1"># or read from config</span>

<span class="k">def</span> <span class="nf">handle_auth_provider</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">auth_payload</span><span class="p">)</span>
  <span class="k">if</span> <span class="n">native_oauth_request?</span>
    <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">from_omniauth</span><span class="p">(</span><span class="n">auth_payload</span><span class="p">)</span> <span class="c1"># Your existing find-or-create logic</span>
    <span class="k">if</span> <span class="n">user</span><span class="p">.</span><span class="nf">persisted?</span>
      <span class="n">token</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">generate_token_for</span><span class="p">(</span><span class="ss">:native_oauth</span><span class="p">)</span>
      <span class="n">redirect_to</span> <span class="s2">"</span><span class="si">#{</span><span class="no">NATIVE_URL_SCHEME</span><span class="si">}</span><span class="s2">://callback?token=</span><span class="si">#{</span><span class="n">token</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
                  <span class="ss">allow_other_host: </span><span class="kp">true</span>
    <span class="k">else</span>
      <span class="n">redirect_to</span> <span class="n">new_user_registration_url</span><span class="p">,</span> <span class="ss">alert: </span><span class="n">user</span><span class="p">.</span><span class="nf">errors</span><span class="p">.</span><span class="nf">full_messages</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">)</span>
    <span class="k">end</span>
    <span class="k">return</span>
  <span class="k">end</span>

  <span class="c1"># ... existing web sign-in flow</span>
<span class="k">end</span>

<span class="kp">private</span>

<span class="c1"># Three detection paths are needed because OAuth callbacks arrive differently:</span>
<span class="c1"># 1. omniauth.params "native" — GET-based providers that preserve query params (e.g. Google)</span>
<span class="c1"># 2. session[:native_oauth]    — set on the trampoline page before the POST to OmniAuth</span>
<span class="c1"># 3. SameSite=None cookie      — survives Apple's cross-origin POST callback where</span>
<span class="c1">#                                  the session cookie (SameSite=Lax) is dropped</span>
<span class="k">def</span> <span class="nf">native_oauth_request?</span>
  <span class="n">request</span><span class="p">.</span><span class="nf">env</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="s2">"omniauth.params"</span><span class="p">,</span> <span class="s2">"native"</span><span class="p">)</span> <span class="o">==</span> <span class="s2">"ios"</span> <span class="o">||</span>
    <span class="n">session</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:native_oauth</span><span class="p">)</span> <span class="o">||</span>
    <span class="n">cookies</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:native_oauth</span><span class="p">)</span> <span class="o">==</span> <span class="s2">"1"</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="6-oauth-button-helper">6. OAuth button helper</h3>

<p>Instead of inlining the native/web branching in every view, use a helper:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/application_helper.rb</span>
<span class="k">def</span> <span class="nf">oauth_button</span><span class="p">(</span><span class="n">provider</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span><span class="p">,</span> <span class="o">&amp;</span><span class="p">)</span>
  <span class="k">if</span> <span class="n">hotwire_native_app?</span>
    <span class="n">token</span> <span class="o">=</span> <span class="n">user_signed_in?</span> <span class="p">?</span> <span class="n">current_user</span><span class="p">.</span><span class="nf">generate_token_for</span><span class="p">(</span><span class="ss">:native_oauth</span><span class="p">)</span> <span class="p">:</span> <span class="kp">nil</span>
    <span class="n">path_params</span> <span class="o">=</span> <span class="n">token</span> <span class="p">?</span> <span class="p">{</span> <span class="ss">user_token: </span><span class="n">token</span> <span class="p">}</span> <span class="p">:</span> <span class="p">{}</span>
    <span class="n">link_to</span><span class="p">(</span><span class="n">native_oauth_start_path</span><span class="p">(</span><span class="n">provider</span><span class="p">,</span> <span class="o">**</span><span class="n">path_params</span><span class="p">),</span>
            <span class="ss">class: </span><span class="n">options</span><span class="p">[</span><span class="ss">:class</span><span class="p">],</span>
            <span class="ss">data: </span><span class="p">{</span> <span class="ss">turbo_frame: </span><span class="s2">"_top"</span> <span class="p">},</span>
            <span class="o">&amp;</span><span class="p">)</span>
  <span class="k">else</span>
    <span class="n">button_to</span><span class="p">(</span><span class="s2">"/auth/</span><span class="si">#{</span><span class="n">provider</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
              <span class="ss">class: </span><span class="n">options</span><span class="p">[</span><span class="ss">:class</span><span class="p">],</span>
              <span class="ss">data: </span><span class="p">{</span> <span class="ss">turbo: </span><span class="kp">false</span> <span class="p">},</span>
              <span class="o">&amp;</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Native renders a <code class="language-plaintext highlighter-rouge">link_to</code> (so Turbo intercepts navigation and the path config routes to ASWebAuthenticationSession). Web renders a <code class="language-plaintext highlighter-rouge">button_to</code> (POST form for OmniAuth). When the user is signed in, a <code class="language-plaintext highlighter-rouge">user_token</code> is automatically included for social account linking.</p>

<p>Usage in views:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%%</span><span class="o">=</span> <span class="n">oauth_button</span><span class="p">(</span><span class="ss">:google_oauth2</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"btn"</span><span class="p">)</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  Sign in with Google
<span class="cp">&lt;%%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<h2 id="ios-changes">iOS changes</h2>

<h3 id="1-register-url-scheme-in-infoplist">1. Register URL scheme in Info.plist</h3>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;key&gt;</span>CFBundleURLTypes<span class="nt">&lt;/key&gt;</span>
<span class="nt">&lt;array&gt;</span>
  <span class="nt">&lt;dict&gt;</span>
    <span class="nt">&lt;key&gt;</span>CFBundleURLName<span class="nt">&lt;/key&gt;</span>
    <span class="nt">&lt;string&gt;</span>com.yourapp.oauth<span class="nt">&lt;/string&gt;</span>
    <span class="nt">&lt;key&gt;</span>CFBundleURLSchemes<span class="nt">&lt;/key&gt;</span>
    <span class="nt">&lt;array&gt;</span>
      <span class="nt">&lt;string&gt;</span>yourapp<span class="nt">&lt;/string&gt;</span>
    <span class="nt">&lt;/array&gt;</span>
  <span class="nt">&lt;/dict&gt;</span>
<span class="nt">&lt;/array&gt;</span>
</code></pre></div></div>

<h3 id="2-path-configuration-rule">2. Path configuration rule</h3>

<p>Add this rule to your path configuration (both local JSON and server-driven). It must come before any catch-all rules:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"patterns"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"^/auth/"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"view_controller"</span><span class="p">:</span><span class="w"> </span><span class="s2">"safari"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="3-endpoint-constants">3. Endpoint constants</h3>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Endpoint.swift (or wherever you keep URL constants)</span>
<span class="kd">extension</span> <span class="kt">Endpoint</span> <span class="p">{</span>
    <span class="kd">enum</span> <span class="kt">OAuth</span> <span class="p">{</span>
        <span class="kd">static</span> <span class="k">let</span> <span class="nv">callbackScheme</span> <span class="o">=</span> <span class="s">"yourapp"</span>
        <span class="kd">static</span> <span class="k">let</span> <span class="nv">nativeSignInURL</span> <span class="o">=</span> <span class="n">rootURL</span><span class="o">.</span><span class="nf">appending</span><span class="p">(</span><span class="nv">path</span><span class="p">:</span> <span class="s">"hotwire_native/sign_in"</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="4-tabbarcontroller-or-your-navigatordelegate">4. TabBarController (or your NavigatorDelegate)</h3>

<p>This is the core iOS change. In <code class="language-plaintext highlighter-rouge">handle(proposal:)</code>, intercept the <code class="language-plaintext highlighter-rouge">"safari"</code> view controller identifier and start an <code class="language-plaintext highlighter-rouge">ASWebAuthenticationSession</code>:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">AuthenticationServices</span>

<span class="kd">class</span> <span class="kt">TabBarController</span><span class="p">:</span> <span class="kt">UITabBarController</span> <span class="p">{</span>
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">authSession</span><span class="p">:</span> <span class="kt">ASWebAuthenticationSession</span><span class="p">?</span>

    <span class="c1">// ... existing code ...</span>
<span class="p">}</span>

<span class="c1">// MARK: - NavigatorDelegate</span>
<span class="kd">extension</span> <span class="kt">TabBarController</span><span class="p">:</span> <span class="kt">NavigatorDelegate</span> <span class="p">{</span>
    <span class="kd">func</span> <span class="nf">handle</span><span class="p">(</span><span class="nv">proposal</span><span class="p">:</span> <span class="kt">VisitProposal</span><span class="p">,</span> <span class="n">from</span> <span class="nv">navigator</span><span class="p">:</span> <span class="kt">Navigator</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">ProposalResult</span> <span class="p">{</span>
        <span class="k">switch</span> <span class="n">proposal</span><span class="o">.</span><span class="n">viewController</span> <span class="p">{</span>
        <span class="k">case</span> <span class="s">"safari"</span><span class="p">:</span>
            <span class="nf">startOAuthSession</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">proposal</span><span class="o">.</span><span class="n">url</span><span class="p">)</span>
            <span class="k">return</span> <span class="o">.</span><span class="n">reject</span>  <span class="c1">// WKWebView never navigates — no frozen state</span>
        <span class="k">default</span><span class="p">:</span>
            <span class="k">return</span> <span class="o">.</span><span class="n">accept</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// MARK: - OAuth</span>
<span class="kd">extension</span> <span class="kt">TabBarController</span> <span class="p">{</span>
    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">startOAuthSession</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="kt">URL</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">var</span> <span class="nv">components</span> <span class="o">=</span> <span class="kt">URLComponents</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="nv">resolvingAgainstBaseURL</span><span class="p">:</span> <span class="kc">false</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

        <span class="c1">// Append native=ios so Rails redirects to the custom URL scheme</span>
        <span class="k">var</span> <span class="nv">queryItems</span> <span class="o">=</span> <span class="n">components</span><span class="o">.</span><span class="n">queryItems</span> <span class="p">??</span> <span class="p">[]</span>
        <span class="n">queryItems</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="kt">URLQueryItem</span><span class="p">(</span><span class="nv">name</span><span class="p">:</span> <span class="s">"native"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="s">"ios"</span><span class="p">))</span>
        <span class="n">components</span><span class="o">.</span><span class="n">queryItems</span> <span class="o">=</span> <span class="n">queryItems</span>

        <span class="k">guard</span> <span class="k">let</span> <span class="nv">oauthURL</span> <span class="o">=</span> <span class="n">components</span><span class="o">.</span><span class="n">url</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

        <span class="k">let</span> <span class="nv">session</span> <span class="o">=</span> <span class="kt">ASWebAuthenticationSession</span><span class="p">(</span>
            <span class="nv">url</span><span class="p">:</span> <span class="n">oauthURL</span><span class="p">,</span>
            <span class="nv">callbackURLScheme</span><span class="p">:</span> <span class="kt">Endpoint</span><span class="o">.</span><span class="kt">OAuth</span><span class="o">.</span><span class="n">callbackScheme</span>
        <span class="p">)</span> <span class="p">{</span> <span class="p">[</span><span class="k">weak</span> <span class="k">self</span><span class="p">]</span> <span class="n">callbackURL</span><span class="p">,</span> <span class="n">error</span> <span class="k">in</span>
            <span class="k">guard</span> <span class="k">let</span> <span class="nv">self</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

            <span class="k">if</span> <span class="k">let</span> <span class="nv">error</span> <span class="p">{</span>
                <span class="k">self</span><span class="o">.</span><span class="n">authSession</span> <span class="o">=</span> <span class="kc">nil</span>
                <span class="k">return</span>
            <span class="p">}</span>

            <span class="k">guard</span> <span class="k">let</span> <span class="nv">callbackURL</span><span class="p">,</span>
                  <span class="k">let</span> <span class="nv">components</span> <span class="o">=</span> <span class="kt">URLComponents</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">callbackURL</span><span class="p">,</span> <span class="nv">resolvingAgainstBaseURL</span><span class="p">:</span> <span class="kc">false</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span>
                <span class="k">self</span><span class="o">.</span><span class="n">authSession</span> <span class="o">=</span> <span class="kc">nil</span>
                <span class="k">return</span>
            <span class="p">}</span>

            <span class="k">let</span> <span class="nv">queryItems</span> <span class="o">=</span> <span class="n">components</span><span class="o">.</span><span class="n">queryItems</span> <span class="p">??</span> <span class="p">[]</span>

            <span class="k">if</span> <span class="k">let</span> <span class="nv">token</span> <span class="o">=</span> <span class="n">queryItems</span><span class="o">.</span><span class="nf">first</span><span class="p">(</span><span class="nv">where</span><span class="p">:</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">name</span> <span class="o">==</span> <span class="s">"token"</span> <span class="p">})?</span><span class="o">.</span><span class="n">value</span> <span class="p">{</span>
                <span class="c1">// Auth sign-in: hand off token to WKWebView</span>
                <span class="kt">Task</span> <span class="p">{</span> <span class="kd">@MainActor</span> <span class="k">in</span>
                    <span class="k">self</span><span class="o">.</span><span class="nf">completeOAuthSignIn</span><span class="p">(</span><span class="nv">token</span><span class="p">:</span> <span class="n">token</span><span class="p">)</span>
                    <span class="k">self</span><span class="o">.</span><span class="n">authSession</span> <span class="o">=</span> <span class="kc">nil</span>
                <span class="p">}</span>
            <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
                <span class="c1">// Social linking: account linked server-side, just reload</span>
                <span class="kt">Task</span> <span class="p">{</span> <span class="kd">@MainActor</span> <span class="k">in</span>
                    <span class="k">self</span><span class="o">.</span><span class="n">activeNavigator</span><span class="p">?</span><span class="o">.</span><span class="nf">reload</span><span class="p">()</span>
                    <span class="k">self</span><span class="o">.</span><span class="n">authSession</span> <span class="o">=</span> <span class="kc">nil</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="n">session</span><span class="o">.</span><span class="n">presentationContextProvider</span> <span class="o">=</span> <span class="k">self</span>
        <span class="n">session</span><span class="o">.</span><span class="n">prefersEphemeralWebBrowserSession</span> <span class="o">=</span> <span class="kc">false</span>  <span class="c1">// Share Safari cookies for SSO</span>
        <span class="n">session</span><span class="o">.</span><span class="nf">start</span><span class="p">()</span>
        <span class="n">authSession</span> <span class="o">=</span> <span class="n">session</span>  <span class="c1">// Strong reference to prevent deallocation</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">completeOAuthSignIn</span><span class="p">(</span><span class="nv">token</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">var</span> <span class="nv">components</span> <span class="o">=</span> <span class="kt">URLComponents</span><span class="p">(</span>
            <span class="nv">url</span><span class="p">:</span> <span class="kt">Endpoint</span><span class="o">.</span><span class="kt">OAuth</span><span class="o">.</span><span class="n">nativeSignInURL</span><span class="p">,</span> <span class="nv">resolvingAgainstBaseURL</span><span class="p">:</span> <span class="kc">false</span>
        <span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

        <span class="n">components</span><span class="o">.</span><span class="n">queryItems</span> <span class="o">=</span> <span class="p">[</span><span class="kt">URLQueryItem</span><span class="p">(</span><span class="nv">name</span><span class="p">:</span> <span class="s">"token"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">token</span><span class="p">)]</span>

        <span class="k">guard</span> <span class="k">let</span> <span class="nv">signInURL</span> <span class="o">=</span> <span class="n">components</span><span class="o">.</span><span class="n">url</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

        <span class="c1">// Navigate WKWebView to the token sign-in endpoint.</span>
        <span class="c1">// This sets the session cookie inside WKWebView.</span>
        <span class="n">activeNavigator</span><span class="p">?</span><span class="o">.</span><span class="nf">route</span><span class="p">(</span><span class="n">signInURL</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// MARK: - ASWebAuthenticationPresentationContextProviding</span>
<span class="kd">extension</span> <span class="kt">TabBarController</span><span class="p">:</span> <span class="kt">ASWebAuthenticationPresentationContextProviding</span> <span class="p">{</span>
    <span class="kd">func</span> <span class="nf">presentationAnchor</span><span class="p">(</span><span class="k">for</span> <span class="nv">session</span><span class="p">:</span> <span class="kt">ASWebAuthenticationSession</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">ASPresentationAnchor</span> <span class="p">{</span>
        <span class="n">view</span><span class="o">.</span><span class="n">window</span> <span class="p">??</span> <span class="kt">ASPresentationAnchor</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="why-aswebauthenticationsession-over-sfsafariviewcontroller">Why ASWebAuthenticationSession over SFSafariViewController</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>ASWebAuthenticationSession</th>
      <th>SFSafariViewController</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Purpose</strong></td>
      <td>Built for OAuth</td>
      <td>General web browsing</td>
    </tr>
    <tr>
      <td><strong>Cookie sharing</strong></td>
      <td>Shares Safari cookies (SSO)</td>
      <td>Isolated cookie store</td>
    </tr>
    <tr>
      <td><strong>Callback</strong></td>
      <td>Built-in completion handler</td>
      <td>Requires NotificationCenter relay</td>
    </tr>
    <tr>
      <td><strong>Bridge component</strong></td>
      <td>Not needed (path config only)</td>
      <td>Needs bridge component (JS + Swift)</td>
    </tr>
    <tr>
      <td><strong>Apple recommendation</strong></td>
      <td>Yes, for authentication</td>
      <td>No, not for auth</td>
    </tr>
  </tbody>
</table>

<h2 id="extending-to-social-account-linking">Extending to social account linking</h2>

<p>When linking social accounts (YouTube, TikTok, etc.), the user is already signed in to WKWebView but <code class="language-plaintext highlighter-rouge">ASWebAuthenticationSession</code> has a separate cookie store. The <code class="language-plaintext highlighter-rouge">oauth_button</code> helper handles this automatically – it generates a <code class="language-plaintext highlighter-rouge">user_token</code> when the user is signed in and passes it through the trampoline.</p>

<p>In the OmniAuth callback, look up the user via the token (with a cookie fallback for Apple’s cross-origin POST):</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">handle_social_provider</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">auth_payload</span><span class="p">)</span>
  <span class="k">if</span> <span class="n">native_oauth_request?</span>
    <span class="c1"># Try omniauth params first, fall back to signed cookie backup</span>
    <span class="n">user_token</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="nf">env</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="s2">"omniauth.params"</span><span class="p">,</span> <span class="s2">"user_token"</span><span class="p">)</span> <span class="o">||</span> <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:native_oauth_user_token</span><span class="p">]</span>
    <span class="n">cookies</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:native_oauth_user_token</span><span class="p">)</span>
    <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by_token_for</span><span class="p">(</span><span class="ss">:native_oauth</span><span class="p">,</span> <span class="n">user_token</span><span class="p">)</span> <span class="k">if</span> <span class="n">user_token</span><span class="p">.</span><span class="nf">present?</span>
    <span class="k">unless</span> <span class="n">user</span>
      <span class="n">redirect_to</span> <span class="s2">"</span><span class="si">#{</span><span class="no">NATIVE_URL_SCHEME</span><span class="si">}</span><span class="s2">://callback?error=unauthenticated"</span><span class="p">,</span> <span class="ss">allow_other_host: </span><span class="kp">true</span>
      <span class="k">return</span>
    <span class="k">end</span>

    <span class="n">social_account</span> <span class="o">=</span> <span class="no">SocialAccount</span><span class="p">.</span><span class="nf">create_or_update_from_omniauth</span><span class="p">(</span><span class="n">auth_payload</span><span class="p">,</span> <span class="n">user</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">social_account</span><span class="p">.</span><span class="nf">persisted?</span>
      <span class="n">redirect_to</span> <span class="s2">"</span><span class="si">#{</span><span class="no">NATIVE_URL_SCHEME</span><span class="si">}</span><span class="s2">://callback?social=linked"</span><span class="p">,</span> <span class="ss">allow_other_host: </span><span class="kp">true</span>
    <span class="k">else</span>
      <span class="n">redirect_to</span> <span class="s2">"</span><span class="si">#{</span><span class="no">NATIVE_URL_SCHEME</span><span class="si">}</span><span class="s2">://callback?error=link_failed"</span><span class="p">,</span> <span class="ss">allow_other_host: </span><span class="kp">true</span>
    <span class="k">end</span>
    <span class="k">return</span>
  <span class="k">end</span>

  <span class="c1"># ... existing web flow</span>
<span class="k">end</span>
</code></pre></div></div>

<p>On iOS, the <code class="language-plaintext highlighter-rouge">ASWebAuthenticationSession</code> completion handler already branches on whether a <code class="language-plaintext highlighter-rouge">token</code> is present (auth sign-in) or not (social linking – just reload the page).</p>

<h2 id="apple-sign-in-gotcha-samesite-cookies">Apple Sign In gotcha: SameSite cookies</h2>

<p>Apple Sign In uses <code class="language-plaintext highlighter-rouge">response_mode=form_post</code> – Apple’s servers POST the callback to your app. This is a cross-origin POST, which causes browsers to drop <code class="language-plaintext highlighter-rouge">SameSite=Lax</code> cookies (the Rails default). Both <code class="language-plaintext highlighter-rouge">session[:native_oauth]</code> and <code class="language-plaintext highlighter-rouge">session</code> itself get lost.</p>

<p>The fix: the trampoline controller sets <code class="language-plaintext highlighter-rouge">SameSite=None</code> cookies as backups:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">cookies[:native_oauth]</code> – detects native OAuth requests when the session is lost</li>
  <li><code class="language-plaintext highlighter-rouge">cookies.signed[:native_oauth_user_token]</code> – preserves the user token for social linking</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">native_oauth_request?</code> method checks three sources:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">omniauth.params["native"]</code> – works for most providers (GET callback)</li>
  <li><code class="language-plaintext highlighter-rouge">session[:native_oauth]</code> – works when the session cookie survives</li>
  <li><code class="language-plaintext highlighter-rouge">cookies[:native_oauth]</code> – <code class="language-plaintext highlighter-rouge">SameSite=None</code> backup for Apple’s cross-origin POST</li>
</ol>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><category term="rails" /><category term="oauth" /><category term="ios" /><category term="swift" /><summary type="html"><![CDATA[Google and Apple block OAuth from embedded web views (WKWebView) with disallowed_useragent. This guide shows how to make OAuth work in a Hotwire Native iOS app using ASWebAuthenticationSession – Apple’s purpose-built API for OAuth – with a path configuration rule and token handoff.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Feature Flags and A/B testing with gem flipper</title><link href="https://blog.superails.com/feature-flags-with-flipper" rel="alternate" type="text/html" title="Feature Flags and A/B testing with gem flipper" /><published>2025-08-02T00:00:00+00:00</published><updated>2025-08-02T00:00:00+00:00</updated><id>https://blog.superails.com/feature-flags-with-flipper</id><content type="html" xml:base="https://blog.superails.com/feature-flags-with-flipper"><![CDATA[<p>3/4 of the last companies I worked with used <a href="https://www.flippercloud.io">gem Flipper</a> for feature flags.</p>

<p><img src="assets/images/flipper-ui.png" alt="flipper ui" /></p>

<p>I also <a href="https://github.com/yshmarov/moneygun/pull/292">added it</a> as a default into <a href="https://github.com/yshmarov/moneygun/pull/292">my SaaS boilerplate</a>.</p>

<p>Quick setup guide:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
bundle add flipper-active_record
bundle add flipper-ui
bin/rails g flipper:setup
rails db:migrate
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">authenticate</span> <span class="ss">:user</span><span class="p">,</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">user</span><span class="p">)</span> <span class="p">{</span> <span class="n">user</span><span class="p">.</span><span class="nf">admin?</span> <span class="o">||</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">env</span><span class="p">.</span><span class="nf">development?</span> <span class="p">}</span> <span class="k">do</span>
  <span class="n">mount</span> <span class="no">Flipper</span><span class="o">::</span><span class="no">UI</span><span class="p">.</span><span class="nf">app</span><span class="p">(</span><span class="no">Flipper</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s2">"/feature_flags"</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/flipper.rb</span>

<span class="c1"># Add User and Organization "actors"</span>
<span class="no">Flipper</span><span class="p">.</span><span class="nf">register</span><span class="p">(</span><span class="ss">:users</span><span class="p">)</span> <span class="p">{</span> <span class="o">|</span><span class="n">actor</span><span class="o">|</span> <span class="n">actor</span><span class="p">.</span><span class="nf">value</span><span class="p">.</span><span class="nf">start_with?</span><span class="p">(</span><span class="s2">"User:"</span><span class="p">)</span> <span class="p">}</span>
<span class="no">Flipper</span><span class="p">.</span><span class="nf">register</span><span class="p">(</span><span class="ss">:organizations</span><span class="p">)</span> <span class="p">{</span> <span class="o">|</span><span class="n">actor</span><span class="o">|</span> <span class="n">actor</span><span class="p">.</span><span class="nf">value</span><span class="p">.</span><span class="nf">start_with?</span><span class="p">(</span><span class="s2">"Organization:"</span><span class="p">)</span> <span class="p">}</span>

<span class="c1"># Clean up flipper UI</span>
<span class="no">Flipper</span><span class="o">::</span><span class="no">UI</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">fun</span> <span class="o">=</span> <span class="kp">false</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">cloud_recommendation</span> <span class="o">=</span> <span class="kp">false</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">show_feature_description_in_list</span> <span class="o">=</span> <span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Usage</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Flipper</span><span class="p">[</span><span class="ss">:search</span><span class="p">].</span><span class="nf">enable</span>
<span class="no">Flipper</span><span class="p">[</span><span class="ss">:search</span><span class="p">].</span><span class="nf">enabled?</span>
<span class="no">Flipper</span><span class="p">[</span><span class="ss">:search</span><span class="p">].</span><span class="nf">disable</span>

<span class="no">Flipper</span><span class="p">.</span><span class="nf">enable</span><span class="p">(</span><span class="ss">:search</span><span class="p">,</span> <span class="no">User</span><span class="p">.</span><span class="nf">first</span><span class="p">)</span>
<span class="no">Flipper</span><span class="p">.</span><span class="nf">enabled?</span><span class="p">(</span><span class="ss">:search</span><span class="p">,</span> <span class="n">current_user</span><span class="p">)</span>
<span class="no">Flipper</span><span class="p">[</span><span class="ss">:search</span><span class="p">].</span><span class="nf">enabled?</span><span class="p">(</span><span class="n">current_user</span><span class="p">)</span>

<span class="no">Flipper</span><span class="p">.</span><span class="nf">enabled?</span><span class="p">(</span><span class="ss">:search</span><span class="p">,</span> <span class="no">Current</span><span class="p">.</span><span class="nf">organization</span><span class="p">)</span>
</code></pre></div></div>]]></content><author><name>Yaroslav Shmarov</name></author><category term="rails" /><category term="feature-flags" /><summary type="html"><![CDATA[3/4 of the last companies I worked with used gem Flipper for feature flags.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Devise Masquerade (Login as) with Avo</title><link href="https://blog.superails.com/devise-masquerade-with-avo-admin" rel="alternate" type="text/html" title="Devise Masquerade (Login as) with Avo" /><published>2025-07-21T00:00:00+00:00</published><updated>2025-07-21T00:00:00+00:00</updated><id>https://blog.superails.com/devise-masquerade-with-avo-admin</id><content type="html" xml:base="https://blog.superails.com/devise-masquerade-with-avo-admin"><![CDATA[<h3 id="basic-devise-masquerade-login-as">Basic <a href="https://github.com/oivoodoo/devise_masquerade">devise masquerade</a> (Login as)</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"devise"</span>
<span class="n">gem</span> <span class="s2">"devise_masquerade"</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/application_controller.rb</span>
<span class="k">class</span> <span class="nc">ApplicationController</span> <span class="o">&lt;</span> <span class="no">ActionController</span><span class="o">::</span><span class="no">Base</span>
    <span class="n">before_action</span> <span class="ss">:authenticate_user!</span>
    <span class="n">before_action</span> <span class="ss">:masquerade_user!</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">devise_for</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">controllers: </span><span class="p">{</span>
  <span class="ss">masquerades: </span><span class="s2">"users/masquerades"</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/users/masquerades_controller.rb</span>
<span class="k">class</span> <span class="nc">Users::MasqueradesController</span> <span class="o">&lt;</span> <span class="no">Devise</span><span class="o">::</span><span class="no">MasqueradesController</span>
  <span class="n">before_action</span> <span class="ss">:authorize_admin</span><span class="p">,</span> <span class="ss">except: :back</span>

  <span class="kp">protected</span>

  <span class="k">def</span> <span class="nf">authorize_admin</span>
    <span class="n">redirect_to</span> <span class="n">root_path</span> <span class="k">unless</span> <span class="n">current_user</span><span class="p">.</span><span class="nf">admin?</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">after_back_masquerade_path_for</span><span class="p">(</span><span class="n">_resource</span><span class="p">)</span>
    <span class="c1"># your admin path</span>
    <span class="no">Avo</span><span class="p">.</span><span class="nf">configuration</span><span class="p">.</span><span class="nf">root_path</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Admin UI: links to log in as a user</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">User</span><span class="p">.</span><span class="nf">all</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span>
  <span class="n">link_to</span> <span class="s2">"Login as"</span><span class="p">,</span> <span class="n">masquerade_path</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>App UI: link to stop imperosnating</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/views/application.html.erb</span>
<span class="o">&lt;</span><span class="sx">% if </span><span class="n">user_masquerade?</span> <span class="sx">%&gt;
  &lt;%= link_to t("devise.masquerade.back"), back_masquerade_path(current_user) %&gt;</span>
<span class="o">&lt;</span><span class="sx">% end </span><span class="o">%&gt;</span>
</code></pre></div></div>

<h3 id="avo">Avo</h3>

<p>You can’t use <code class="language-plaintext highlighter-rouge">masquerade_path(User.last)</code> from Avo. So I had to create a helper:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/application_helper.rb</span>
<span class="k">module</span> <span class="nn">ApplicationHelper</span>

  <span class="k">def</span> <span class="nf">avo_masquerade_path</span><span class="p">(</span><span class="n">resource</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">)</span>
    <span class="n">scope</span> <span class="o">=</span> <span class="no">Devise</span><span class="o">::</span><span class="no">Mapping</span><span class="p">.</span><span class="nf">find_scope!</span><span class="p">(</span><span class="n">resource</span><span class="p">)</span>

    <span class="n">opts</span> <span class="o">=</span> <span class="n">args</span><span class="p">.</span><span class="nf">shift</span> <span class="o">||</span> <span class="p">{}</span>
    <span class="n">opts</span><span class="p">[</span><span class="ss">:masqueraded_resource_class</span><span class="p">]</span> <span class="o">=</span> <span class="n">resource</span><span class="p">.</span><span class="nf">class</span><span class="p">.</span><span class="nf">name</span>

    <span class="n">opts</span><span class="p">[</span><span class="no">Devise</span><span class="p">.</span><span class="nf">masquerade_param</span><span class="p">]</span> <span class="o">=</span> <span class="n">resource</span><span class="p">.</span><span class="nf">masquerade_key</span>

    <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">url_helpers</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="ss">:"</span><span class="si">#{</span><span class="n">scope</span><span class="si">}</span><span class="ss">_masquerade_index_path"</span><span class="p">,</span> <span class="n">opts</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Use it in Avo Admin UI:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/avo/resources/user.rb</span>
<span class="k">class</span> <span class="nc">Avo::Resources::User</span> <span class="o">&lt;</span> <span class="no">Avo</span><span class="o">::</span><span class="no">BaseResource</span>
  <span class="k">def</span> <span class="nf">fields</span>
    <span class="n">field</span> <span class="ss">:login_as</span><span class="p">,</span> <span class="ss">as: :text</span><span class="p">,</span> <span class="ss">as_html: </span><span class="kp">true</span> <span class="k">do</span>
      <span class="k">unless</span> <span class="n">record</span><span class="p">.</span><span class="nf">id</span> <span class="o">==</span> <span class="n">current_user</span><span class="p">.</span><span class="nf">id</span>
        <span class="n">link_to</span> <span class="s2">"Login as"</span><span class="p">,</span> <span class="n">helpers</span><span class="p">.</span><span class="nf">avo_masquerade_path</span><span class="p">(</span><span class="n">record</span><span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h1 id="avo-canon-approach">Avo canon approach</h1>

<p>After <a href="https://discord.com/channels/740892036978442260/1125160641569771550/1380485383778730074">highlighting this issue in Avo discord</a>, Paul came up with a solution:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl -o config/initializers/devise_masquerade_engine_patch.rb https://gist.githubusercontent.com/Paul-Bob/9f86cac656c1f5464ad9d423258538c8/raw/8c7dc52e963792ef6e8bf24e57cbe61bd40fb1c5/devise_masquerade_engine_patch.rb
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/devise_masquerade_engine_patch.rb</span>

<span class="c1"># Patch for DeviseMasquerade::Controllers::UrlHelpers</span>
<span class="c1">#</span>
<span class="c1"># Problem:</span>
<span class="c1"># - Devise Masquerade defines `_masquerade_index_path` helpers dynamically based on scope.</span>
<span class="c1"># - These helpers are only registered in the main application's routes.</span>
<span class="c1"># - When called from within an engine (e.g. Avo), these helpers may not be found, raising errors.</span>
<span class="c1">#</span>
<span class="c1"># Solution:</span>
<span class="c1"># - This patch intercepts missing `_masquerade_index_path` method calls.</span>
<span class="c1"># - If missing, it forwards them to the main application's route helpers.</span>
<span class="c1"># - This ensures compatibility with engines that rely on these routes indirectly.</span>

<span class="k">module</span> <span class="nn">DeviseMasquerade</span>
  <span class="k">module</span> <span class="nn">Controllers</span>
    <span class="k">module</span> <span class="nn">UrlHelpers</span>
      <span class="k">def</span> <span class="nf">method_missing</span><span class="p">(</span><span class="n">method_name</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">method_name</span><span class="p">.</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">end_with?</span><span class="p">(</span><span class="s2">"_masquerade_index_path"</span><span class="p">)</span>
          <span class="o">::</span><span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">url_helpers</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="n">method_name</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
        <span class="k">else</span>
          <span class="k">super</span>
        <span class="k">end</span>
      <span class="k">end</span>

      <span class="k">def</span> <span class="nf">respond_to_missing?</span><span class="p">(</span><span class="n">method_name</span><span class="p">,</span> <span class="n">include_private</span> <span class="o">=</span> <span class="kp">false</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">method_name</span><span class="p">.</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">end_with?</span><span class="p">(</span><span class="s2">"_masquerade_index_path"</span><span class="p">)</span>
          <span class="o">::</span><span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">url_helpers</span><span class="p">.</span><span class="nf">respond_to?</span><span class="p">(</span><span class="n">method_name</span><span class="p">)</span>
        <span class="k">else</span>
          <span class="k">super</span>
        <span class="k">end</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># app/avo/resources/user.rb
<span class="p">class Avo::Resources::User &lt; Avo::BaseResource
</span>  def fields
    field :login_as, as: :text, as_html: true do
      unless record.id == current_user.id
<span class="gd">-          link_to "Login as", helpers.avo_masquerade_path(record)
</span><span class="gi">+          link_to "Login as", masquerade_path(record)
</span>      end
    end
  end
<span class="p">end
</span></code></pre></div></div>

<p>That’s it!</p>

<p>Next, see how User impersonation works in <a href="https://github.com/yshmarov/moneygun">Moneygun app bolierplate</a></p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="avo" /><category term="impersonation" /><summary type="html"><![CDATA[Basic devise masquerade (Login as)]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Ruby AI. Vector (semantic) search with embeddings</title><link href="https://blog.superails.com/ai-vector-search" rel="alternate" type="text/html" title="Ruby AI. Vector (semantic) search with embeddings" /><published>2025-06-30T00:00:00+00:00</published><updated>2025-06-30T00:00:00+00:00</updated><id>https://blog.superails.com/ai-vector-search</id><content type="html" xml:base="https://blog.superails.com/ai-vector-search"><![CDATA[<p>Best ways to search in Rails, from simple to advanced:</p>

<ol>
  <li>SQL <code class="language-plaintext highlighter-rouge">ILIKE</code></li>
  <li>gem ransack <code class="language-plaintext highlighter-rouge">name_or_description_cont</code></li>
  <li><a href="/install-gem-pg_search">gem pg_search</a> - advanced Postgres search</li>
  <li>indexed search with typesense/elasticsearch/algolia</li>
  <li>AI search with embeddings</li>
</ol>

<p>AI search lets you search by “meaning”, not by keywords!</p>

<p>How AI search works:</p>

<ol>
  <li>create embeddings: turn texts into vectors</li>
  <li>search embeddings with gem neighbour</li>
</ol>

<h2 id="install-dependencies">Install dependencies</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="c1"># pg extension to enable vectors</span>
<span class="c1"># https://github.com/pgvector/pgvector-ruby</span>
<span class="n">gem</span> <span class="s2">"pgvector"</span>
<span class="c1"># vector search</span>
<span class="c1"># https://github.com/ankane/neighbor</span>
<span class="n">gem</span> <span class="s2">"neighbor"</span>
<span class="c1"># create vectors</span>
<span class="c1"># https://github.com/crmne/ruby_llm</span>
<span class="n">gem</span> <span class="s2">"ruby_llm"</span>
</code></pre></div></div>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>pgvector
</code></pre></div></div>

<p>If it doesn’t work, run</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> ~/tmp
<span class="nb">cd</span> ~/tmp
git clone <span class="nt">--branch</span> v0.8.0 https://github.com/pgvector/pgvector.git
<span class="nb">cd </span>pgvector

<span class="c"># Build and install for DocumentgreSQL 16</span>
make <span class="nv">USE_PGXS</span><span class="o">=</span>1 <span class="nv">PG_CONFIG</span><span class="o">=</span>/opt/homebrew/opt/postgresql@16/bin/pg_config
<span class="nb">sudo </span>make <span class="nv">USE_PGXS</span><span class="o">=</span>1 <span class="nv">PG_CONFIG</span><span class="o">=</span>/opt/homebrew/opt/postgresql@16/bin/pg_config <span class="nb">install</span>

<span class="c"># Restart DocumentgreSQL</span>
brew services restart postgresql@16
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/neighbor.rb</span>
<span class="no">Neighbor</span><span class="o">::</span><span class="no">PostgreSQL</span><span class="p">.</span><span class="nf">initialize!</span>
</code></pre></div></div>

<p>Run migrations:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">rails</span> <span class="n">g</span> <span class="n">migration</span> <span class="no">InstallNeighborVector</span>
<span class="n">rails</span> <span class="n">g</span> <span class="n">migration</span> <span class="no">AddEmbeddingToDocuments</span>
<span class="n">rails</span> <span class="n">g</span> <span class="n">model</span> <span class="no">Document</span> <span class="n">title</span> <span class="n">content</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">InstallNeighborVector</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">7.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">enable_extension</span> <span class="s2">"vector"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">AddEmbeddingToDocuments</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">7.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">add_column</span> <span class="ss">:documents</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">:vector</span><span class="p">,</span> <span class="ss">limit: </span><span class="mi">1536</span><span class="p">,</span> <span class="ss">if_not_exists: </span><span class="kp">true</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="create-embeddings">Create embeddings:</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/document.rb</span>
  <span class="nb">self</span><span class="p">.</span><span class="nf">filter_attributes</span> <span class="o">+=</span> <span class="p">[</span><span class="ss">:embedding</span><span class="p">]</span>

  <span class="n">validates</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>
  <span class="n">validates</span> <span class="ss">:content</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>

  <span class="n">has_neighbors</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">dimensions: </span><span class="mi">1536</span>
  <span class="n">before_save</span> <span class="ss">:generate_embedding</span><span class="p">,</span> <span class="ss">if: </span><span class="o">-&gt;</span> <span class="p">{</span> <span class="n">saved_change_to_title?</span> <span class="o">||</span> <span class="n">saved_change_to_content?</span> <span class="p">}</span>

  <span class="n">scope</span> <span class="ss">:search_by_similarity</span><span class="p">,</span> <span class="o">-&gt;</span> <span class="p">(</span><span class="n">query_text</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">query_embedding</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">embed</span><span class="p">(</span><span class="n">query_text</span><span class="p">).</span><span class="nf">vectors</span>

    <span class="c1"># distance: :inner_product</span>
    <span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">query_embedding</span><span class="p">,</span> <span class="ss">distance: :cosine</span><span class="p">).</span><span class="nf">first</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="c1"># def text_for_embedding</span>
  <span class="c1">#   &lt;&lt;~EOS</span>
  <span class="c1">#     Title: #{title}</span>
  <span class="c1">#     Content: #{content}</span>
  <span class="c1">#   EOS</span>
  <span class="c1"># end</span>

  <span class="k">def</span> <span class="nf">generate_embedding</span>
    <span class="n">text_for_embedding</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s2">"Title: </span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
      <span class="s2">"Content: </span><span class="si">#{</span><span class="n">content</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
    <span class="p">].</span><span class="nf">compact</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"</span><span class="se">\n</span><span class="s2">---</span><span class="se">\n</span><span class="s2">"</span><span class="p">)</span>

    <span class="k">begin</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">embedding</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">embed</span><span class="p">(</span><span class="n">text_for_embedding</span><span class="p">).</span><span class="nf">vectors</span>
    <span class="k">rescue</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Error</span> <span class="o">=&gt;</span> <span class="n">e</span>
    <span class="k">end</span>
  <span class="k">end</span>
</code></pre></div></div>

<h2 id="perform-search">Perform search:</h2>

<p>Console</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Document</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="ss">content: </span><span class="s2">"Company HR policy: Employees must..."</span><span class="p">)</span>
<span class="no">Document</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="ss">content: </span><span class="s2">"Company internal documentation: ..."</span><span class="p">)</span>

<span class="n">documents</span> <span class="o">=</span> <span class="no">Document</span><span class="p">.</span><span class="nf">search_by_similarity</span><span class="p">(</span><span class="s2">"What is the company's remote work policy?"</span><span class="p">)</span>
<span class="n">documents</span><span class="p">.</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">document</span><span class="o">|</span> <span class="nb">puts</span> <span class="s2">"- </span><span class="si">#{</span><span class="n">document</span><span class="p">.</span><span class="nf">content</span><span class="si">}</span><span class="s2">"</span> <span class="p">}</span>
</code></pre></div></div>

<p>Controller</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/documents_controller.rb</span>
<span class="k">def</span> <span class="nf">index</span>
  <span class="vi">@documents</span> <span class="o">=</span> <span class="k">if</span> <span class="n">params</span><span class="p">[</span><span class="ss">:q</span><span class="p">].</span><span class="nf">present?</span>
    <span class="no">Document</span><span class="p">.</span><span class="nf">all</span><span class="p">.</span><span class="nf">search_by_similarity</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:q</span><span class="p">])</span>
  <span class="k">else</span>
    <span class="no">Document</span><span class="p">.</span><span class="nf">all</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="make-it-work-on-ci">Make it work on CI</h2>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># ci
<span class="gd">-image: postgres:11-alpine
</span><span class="gi">+image: pgvector/pgvector:pg16
</span></code></pre></div></div>

<h2 id="next-steps">Next steps</h2>

<ul>
  <li>Rate limiting; do not do typeahead search (too many requests &amp; token$)</li>
  <li>Add caching for popular queries (Query <code class="language-plaintext highlighter-rouge">string</code> &amp; <code class="language-plaintext highlighter-rouge">embedding</code> pairs)</li>
  <li>Let user see his recent searches / recently visited (might have to do fewer search queries)</li>
</ul>

<h2 id="inspired-by">Inspired by</h2>

<p>https://d-caponi1.medium.com/getting-set-up-with-vector-databases-in-rails-8-ac1fa2fb5b48
https://medium.com/@mauricio/how-to-add-recommendations-to-a-rails-app-with-pgvector-and-openai-881d87915fb2
https://liambx.com/blog/semantic-search-rails-neighbor-gem</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="rubyllm" /><category term="neighbour" /><category term="pgvector" /><category term="ai" /><summary type="html"><![CDATA[Best ways to search in Rails, from simple to advanced:]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Staying Competitive - My Talk at Balkan Ruby 2025</title><link href="https://blog.superails.com/balkan-ruby-staying-competitive" rel="alternate" type="text/html" title="Staying Competitive - My Talk at Balkan Ruby 2025" /><published>2025-06-29T00:00:00+00:00</published><updated>2025-06-29T00:00:00+00:00</updated><id>https://blog.superails.com/balkan-ruby-staying-competitive</id><content type="html" xml:base="https://blog.superails.com/balkan-ruby-staying-competitive"><![CDATA[<p>I recently had the honor of speaking at <a href="https://balkanruby.com/">Balkan Ruby 2025</a> in Sofia, Bulgaria. My talk “Staying Competitive” focused on how developers can thrive in today’s rapidly evolving tech landscape, especially with the rise of AI tools and globalized remote work.</p>

<h2 id="-the-challenge-staying-relevant-in-a-global-market">🎯 The Challenge: Staying Relevant in a Global Market</h2>

<p>The tech industry has changed dramatically. Remote work has globalized the job market, meaning you’re now competing with developers from around the world for the same positions. When a remote job posting can attract thousands of applications, standing out becomes crucial.</p>

<h2 id="-embrace-ai-tools-or-risk-being-left-behind">🤖 Embrace AI Tools or Risk Being Left Behind</h2>

<p>One of my key messages was about embracing AI coding tools like <strong>Cursor</strong> and <strong>GitHub Copilot</strong>. These aren’t just nice-to-have tools anymore—they’re becoming essential for productivity.</p>

<p><strong>The reality:</strong> Developers who resist these changes may find themselves at a disadvantage. AI tools can significantly boost coding efficiency, and companies are increasingly expecting developers to leverage these technologies.</p>

<h3 id="why-ai-coding-tools-matter">Why AI Coding tools Matter:</h3>

<ul>
  <li><strong>Increased productivity</strong> - Write code faster with intelligent suggestions</li>
  <li><strong>Better code quality</strong> - AI can catch patterns and suggest improvements</li>
  <li><strong>Learning acceleration</strong> - AI can explain concepts and provide examples</li>
  <li><strong>Competitive advantage</strong> - Stay ahead of developers who aren’t using these tools</li>
</ul>

<h2 id="-how-to-stand-out-in-job-applications">🚀 How to Stand Out in Job Applications</h2>

<p>With thousands of applicants for remote positions, you need to differentiate yourself. Here’s what I recommend:</p>

<h3 id="1-build-a-public-portfolio">1. Build a Public Portfolio</h3>

<ul>
  <li>Create open-source projects</li>
  <li>Contribute to existing projects</li>
  <li>Showcase your work on GitHub</li>
</ul>

<h3 id="2-write-a-technical-blog">2. Write a Technical Blog</h3>

<ul>
  <li>Document your learning journey</li>
  <li>Share solutions to problems you’ve solved</li>
  <li>Demonstrate your thought process and expertise</li>
</ul>

<h3 id="3-create-content">3. Create Content</h3>

<ul>
  <li>YouTube tutorials and walkthroughs</li>
  <li>Podcast appearances</li>
  <li>Conference talks (like this one!)</li>
  <li>Social media presence</li>
</ul>

<h2 id="-networking-the-hidden-job-market">🤝 Networking: The Hidden Job Market</h2>

<p>Many job opportunities never make it to public job boards. They’re filled through personal connections and referrals. This is why networking is more important than ever:</p>

<ul>
  <li><strong>Attend conferences</strong> like Balkan Ruby</li>
  <li><strong>Join online communities</strong> (Discord, Slack, forums)</li>
  <li><strong>Participate in meetups</strong> (virtual or in-person)</li>
  <li><strong>Engage with the community</strong> on social media</li>
</ul>

<h2 id="-content-creation-in-the-ai-era">📝 Content Creation in the AI Era</h2>

<p>As a content creator myself, I’ve noticed a significant shift. Many of my previous tutorials can now be easily answered by AI tools. This means content creators need to adapt:</p>

<h3 id="what-still-works">What Still Works:</h3>

<ul>
  <li><strong>Complex setup walkthroughs</strong> - AI struggles with multi-step processes</li>
  <li><strong>Tool-specific tutorials</strong> - Deep dives into specific technologies</li>
  <li><strong>Real-world problem solving</strong> - Showing actual debugging and troubleshooting</li>
  <li><strong>Personal experiences</strong> - Stories and insights AI can’t replicate</li>
</ul>

<h3 id="whats-changing">What’s Changing:</h3>

<ul>
  <li>Basic “how-to” content is becoming less valuable</li>
  <li>Focus on unique perspectives and experiences</li>
  <li>Create content that shows your personality and expertise</li>
</ul>

<h2 id="️-the-rise-of-no-code-tools">🛠️ The Rise of No-Code Tools</h2>

<p>No-code platforms are democratizing development, allowing rapid prototyping without traditional coding. While this might seem threatening to developers, it actually creates opportunities:</p>

<ul>
  <li><strong>Integration expertise</strong> - Help businesses connect no-code tools</li>
  <li><strong>Custom development</strong> - Build features that no-code tools can’t handle</li>
  <li><strong>Consulting</strong> - Guide companies on when to use no-code vs custom development</li>
</ul>

<h2 id="-continuous-learning-and-adaptation">🔄 Continuous Learning and Adaptation</h2>

<p>The key to staying competitive is continuous learning and adaptation:</p>

<h3 id="daily-habits">Daily Habits:</h3>

<ul>
  <li><strong>Stay updated</strong> with industry trends</li>
  <li><strong>Learn new tools</strong> as they emerge</li>
  <li><strong>Practice regularly</strong> with side projects</li>
  <li><strong>Network consistently</strong> with other developers</li>
</ul>

<h3 id="long-term-strategy">Long-term Strategy:</h3>

<ul>
  <li><strong>Build in public</strong> - Share your learning journey</li>
  <li><strong>Develop unique skills</strong> - Find your niche</li>
  <li><strong>Create value</strong> - Solve real problems for real people</li>
  <li><strong>Stay curious</strong> - Always be learning something new</li>
</ul>

<h2 id="-key-takeaways">💡 Key Takeaways</h2>

<ol>
  <li><strong>Embrace AI tools</strong> - They’re here to stay and can make you more productive</li>
  <li><strong>Work in public</strong> - Build your personal brand and showcase your expertise</li>
  <li><strong>Network actively</strong> - Many opportunities come through personal connections</li>
  <li><strong>Adapt your content</strong> - Focus on what AI can’t replicate</li>
  <li><strong>Never stop learning</strong> - The tech landscape changes rapidly</li>
</ol>

<h2 id="-thank-you-balkan-ruby">🎉 Thank You Balkan Ruby!</h2>

<p>I want to thank the Balkan Ruby team for organizing such an amazing conference and giving me the opportunity to share these insights. The Ruby community in the Balkans is incredibly vibrant and welcoming.</p>

<p>If you’re interested in more content like this, check out my <a href="https://www.youtube.com/@SupeRails">YouTube channel</a> and <a href="https://superails.com">blog</a> where I share practical tips for Rails developers.</p>

<hr />

<p><em>What strategies are you using to stay competitive in today’s tech landscape? I’d love to hear your thoughts in the comments below!</em></p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="conference" /><category term="balkanruby" /><category term="ai" /><category term="productivity" /><category term="career" /><summary type="html"><![CDATA[I recently had the honor of speaking at Balkan Ruby 2025 in Sofia, Bulgaria. My talk “Staying Competitive” focused on how developers can thrive in today’s rapidly evolving tech landscape, especially with the rise of AI tools and globalized remote work.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Separate Google and YouTube OAuth Strategies in Rails</title><link href="https://blog.superails.com/youtube-oauth" rel="alternate" type="text/html" title="Separate Google and YouTube OAuth Strategies in Rails" /><published>2025-06-21T00:00:00+00:00</published><updated>2025-06-21T00:00:00+00:00</updated><id>https://blog.superails.com/youtube-oauth</id><content type="html" xml:base="https://blog.superails.com/youtube-oauth"><![CDATA[<p>Sometimes you need to allow users to authenticate with <strong>Google</strong> while also connecting their <strong>YouTube</strong> accounts separately. This can be tricky because the <code class="language-plaintext highlighter-rouge">omniauth-google-oauth2</code> gem doesn’t provide a separate YouTube OAuth strategy.</p>

<h2 id="the-challenge">The Challenge</h2>

<p>The Google OAuth strategy distinguishes between Google and YouTube OAuth only by scopes:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">https://www.googleapis.com/auth/youtube.readonly</code> - Access basic YouTube account data (can be used for auth or “validate ownership”)</li>
  <li><code class="language-plaintext highlighter-rouge">https://www.googleapis.com/auth/youtube.force-ssl</code> - Write access to YouTube account</li>
</ul>

<p>Here’s what the OAuth screens look like:</p>

<p><strong>Google OAuth screen:</strong>
<img src="/assets/images/google-oauth-personal.png" alt="Google OAuth Personal" /></p>

<p><strong>YouTube OAuth screen:</strong>
<img src="/assets/images/google-oauth-youtube.png" alt="Google OAuth YouTube" /></p>

<h2 id="solution-custom-youtube-oauth-strategy">Solution: Custom YouTube OAuth Strategy</h2>

<p>To separate these OAuth strategies, we can create a custom YouTube OAuth strategy on top of the Google OAuth2 strategy:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/omniauth.rb</span>
<span class="nb">require</span> <span class="s2">"omniauth"</span>
<span class="nb">require</span> <span class="s2">"omniauth-google-oauth2"</span>

<span class="k">module</span> <span class="nn">OmniAuth</span>
  <span class="k">module</span> <span class="nn">Strategies</span>
    <span class="k">class</span> <span class="nc">Youtube</span> <span class="o">&lt;</span> <span class="no">OmniAuth</span><span class="o">::</span><span class="no">Strategies</span><span class="o">::</span><span class="no">GoogleOauth2</span>
      <span class="n">option</span> <span class="ss">:name</span><span class="p">,</span> <span class="s2">"youtube"</span>

      <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">app</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
        <span class="k">super</span>
        <span class="n">options</span><span class="p">.</span><span class="nf">scope</span> <span class="o">=</span> <span class="s2">"email,profile,https://www.googleapis.com/auth/youtube.readonly"</span>
        <span class="c1"># For write access, use:</span>
        <span class="c1"># options.scope = "email,profile,https://www.googleapis.com/auth/youtube.force-ssl,https://www.googleapis.com/auth/youtube.readonly"</span>

        <span class="c1"># Use the same redirect URI as Google OAuth2</span>
        <span class="c1"># options.redirect_uri = "http://localhost:3000/users/auth/youtube/callback"</span>
        <span class="c1"># options.redirect_uri = "http://localhost:3000/users/auth/google_oauth2/callback"</span>
        <span class="c1"># options.redirect_uri = "https://contentprize.com/users/auth/youtube/callback"</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="configure-devise">Configure Devise</h2>

<p>Add YouTube as a separate OAuth strategy in your Devise configuration:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/devise.rb</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">omniauth</span> <span class="ss">:google_oauth2</span><span class="p">,</span>
                  <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">credentials</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="ss">:google_oauth2</span><span class="p">,</span> <span class="ss">:key</span><span class="p">),</span>
                  <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">credentials</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="ss">:google_oauth2</span><span class="p">,</span> <span class="ss">:secret</span><span class="p">),</span>
                  <span class="ss">scope: </span><span class="s2">"email,profile"</span>

  <span class="c1"># Add YouTube as a separate provider using the same Google credentials</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">omniauth</span> <span class="ss">:youtube</span><span class="p">,</span>
                  <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">credentials</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="ss">:google_oauth2</span><span class="p">,</span> <span class="ss">:key</span><span class="p">),</span>
                  <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">credentials</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="ss">:google_oauth2</span><span class="p">,</span> <span class="ss">:secret</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="update-user-model">Update User Model</h2>

<p>Ensure that YouTube is included in the list of Devise OAuth providers:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/user.rb</span>
<span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">devise</span> <span class="ss">:database_authenticatable</span><span class="p">,</span> <span class="ss">:registerable</span><span class="p">,</span>
         <span class="ss">:recoverable</span><span class="p">,</span> <span class="ss">:rememberable</span><span class="p">,</span> <span class="ss">:validatable</span><span class="p">,</span>
         <span class="ss">:omniauthable</span><span class="p">,</span> <span class="ss">omniauth_providers: </span><span class="no">Devise</span><span class="p">.</span><span class="nf">omniauth_configs</span><span class="p">.</span><span class="nf">keys</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="google-cloud-console-setup">Google Cloud Console Setup</h2>

<p>In the Google Cloud Console, add separate callback URLs for both providers:</p>

<p><img src="/assets/images/google-and-youtube-oauth-callbacks.png" alt="Google and YouTube OAuth Callbacks" /></p>

<h2 id="handle-oauth-callbacks">Handle OAuth Callbacks</h2>

<p>Create a controller to handle both Google and YouTube OAuth callbacks:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/users/omniauth_callbacks_controller.rb</span>
<span class="k">class</span> <span class="nc">Users::OmniauthCallbacksController</span> <span class="o">&lt;</span> <span class="no">Devise</span><span class="o">::</span><span class="no">OmniauthCallbacksController</span>
  <span class="no">Devise</span><span class="p">.</span><span class="nf">omniauth_configs</span><span class="p">.</span><span class="nf">keys</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">provider</span><span class="o">|</span>
    <span class="n">define_method</span> <span class="n">provider</span> <span class="k">do</span>
      <span class="n">handle_auth</span> <span class="n">provider</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">failure</span>
    <span class="n">redirect_to</span> <span class="n">new_user_registration_url</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Something went wrong. Try again."</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">handle_auth</span><span class="p">(</span><span class="n">kind</span><span class="p">)</span>
    <span class="n">auth_payload</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="nf">env</span><span class="p">[</span><span class="s2">"omniauth.auth"</span><span class="p">]</span>

    <span class="c1"># TODO: Handle logic for User oAuth</span>
    <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">from_omniauth</span><span class="p">(</span><span class="n">auth_payload</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">user</span><span class="p">.</span><span class="nf">persisted?</span>
      <span class="n">flash</span><span class="p">[</span><span class="ss">:notice</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"Successfully authenticated with </span><span class="si">#{</span><span class="n">kind</span><span class="p">.</span><span class="nf">titleize</span><span class="si">}</span><span class="s2">!"</span>
      <span class="n">sign_in_and_redirect</span> <span class="n">user</span><span class="p">,</span> <span class="ss">event: :authentication</span>
    <span class="k">else</span>
      <span class="n">redirect_to</span> <span class="n">new_user_registration_url</span><span class="p">,</span> <span class="ss">alert: </span><span class="n">user</span><span class="p">.</span><span class="nf">errors</span><span class="p">.</span><span class="nf">full_messages</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This way users can authenticate with <strong>Google</strong> or <strong>Youtube</strong></p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="oauth" /><category term="omniauth" /><category term="devise" /><category term="google" /><category term="youtube" /><category term="rails" /><summary type="html"><![CDATA[Sometimes you need to allow users to authenticate with Google while also connecting their YouTube accounts separately. This can be tricky because the omniauth-google-oauth2 gem doesn’t provide a separate YouTube OAuth strategy.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">SupeRails Hack Space @ Friendly.rb 2025</title><link href="https://blog.superails.com/superails-friendlyrb-2025" rel="alternate" type="text/html" title="SupeRails Hack Space @ Friendly.rb 2025" /><published>2025-06-05T00:00:00+00:00</published><updated>2025-06-05T00:00:00+00:00</updated><id>https://blog.superails.com/superails-friendlyrb-2025</id><content type="html" xml:base="https://blog.superails.com/superails-friendlyrb-2025"><![CDATA[<p>Last year at FriendlyRB 2024, pair coding in the hallway was one of the most memorable experiences. This year, we’re taking it to the next level!</p>

<div style="display: flex; gap: 20px; margin: 20px 0;">
  <img src="/assets/images/friendly-superails-1.jpeg" alt="Friendly.rb SupeRails 1" style="width: 50%; height: 300px; object-fit: cover; border-radius: 8px;" />
  <img src="/assets/images/friendly-superails-2.jpg" alt="Friendly.rb SupeRails 2" style="width: 50%; height: 300px; object-fit: cover; border-radius: 8px;" />
</div>

<h2 id="-whats-new-this-year">🎯 What’s New This Year?</h2>

<p>SupeRails will be leading the <strong>hallway track</strong> with our brand new <strong>Hack Space</strong>! Join us for an exciting lineup of hands-on activities and collaborative sessions.</p>

<h2 id="-event-schedule">📅 Event Schedule</h2>

<h3 id="day-1">Day 1</h3>

<ul>
  <li><strong>09:00 - 18:00</strong> 🚀 Launch your own SaaS app in a day</li>
  <li><strong>18:00</strong> 🤫 Surprise for attendees 🎁</li>
</ul>

<h3 id="day-2">Day 2</h3>

<ul>
  <li><strong>09:00 - 12:00</strong> 💻 Hack session: let’s help each other</li>
  <li><strong>12:00 - 13:00</strong> 🥑 Showcase: how I use Avo</li>
  <li><strong>13:00 - 18:00</strong> 🎙️ Start a Podcast (come with a friend)</li>
  <li><strong>18:00</strong> 🤫 Surprise for attendees 🎁</li>
</ul>

<p><em>Note: Schedule times are subject to change</em></p>

<hr />

<p><em>Join us for an unforgettable experience of learning and building!</em></p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="conference" /><category term="superails" /><category term="friendlyrb" /><summary type="html"><![CDATA[Last year at FriendlyRB 2024, pair coding in the hallway was one of the most memorable experiences. This year, we’re taking it to the next level!]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Big family is the new flex</title><link href="https://blog.superails.com/big-family-stats-symbol" rel="alternate" type="text/html" title="Big family is the new flex" /><published>2025-05-07T00:00:00+00:00</published><updated>2025-05-07T00:00:00+00:00</updated><id>https://blog.superails.com/big-family-stats-symbol</id><content type="html" xml:base="https://blog.superails.com/big-family-stats-symbol"><![CDATA[<p>You open Instagram — and suddenly, everyone’s having babies.<br />
👶 Big, happy families.<br />
👗 Matching outfits.<br />
🍼 Wholesome chaos.</p>

<p><img src="/assets/images/big-family-1.PNG" alt="big family 1" /></p>

<p>But look closer — they’re all <em>wealthy</em>.<br />
💸 Turns out, having lots of kids is the new status symbol.</p>

<p>Just 20 years ago, large families were usually a sign of limited means. Feeding five or more people was tough, and the living conditions reflected that. The wealthy, meanwhile, stuck to one or two kids — it made sense.</p>

<p><img src="/assets/images/big-family-2.PNG" alt="big family 2" /></p>

<p>📜 Go back 150 years, and it was even clearer:<br />
Peasants had more children because every extra pair of hands helped on the farm. More kids meant more work done — and a better shot at survival.</p>

<p>🏰 In elite families, it was the opposite. Children meant splitting inheritance more ways. So, one heir — preferably a son — was often enough.</p>

<p>But that logic is gone.<br />
Today, having three or more kids quietly says:<br />
🧸 “We can afford to give <em>each</em> of them a great life.”</p>

<p>Millennials and Gen Z, raised in economic uncertainty, want better for their kids:<br />
🔒 Stability<br />
🛏️ Space<br />
🎓 Opportunity<br />
🧠 Autonomy<br />
💞 Emotional safety</p>

<p>And that’s expensive.</p>

<p><img src="/assets/images/big-family-3.PNG" alt="big family 3" /></p>

<p>Nannies, tutors, time, attention — it all adds up.<br />
So while luxury used to mean yachts and watches ⛵⌚, now it looks more like:<br />
🍽️ Dinner at a big table<br />
🖐️ Sticky fingers<br />
🏡 A noisy house full of love</p>

<p>Honestly?<br />
It feels like a healthier kind of wealth.</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="life" /><summary type="html"><![CDATA[You open Instagram — and suddenly, everyone’s having babies. 👶 Big, happy families. 👗 Matching outfits. 🍼 Wholesome chaos.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Google One Tap Authentication with Rails 8 and Devise</title><link href="https://blog.superails.com/google-onetap-oauth" rel="alternate" type="text/html" title="Google One Tap Authentication with Rails 8 and Devise" /><published>2025-05-04T00:00:00+00:00</published><updated>2025-05-04T00:00:00+00:00</updated><id>https://blog.superails.com/google-onetap-oauth</id><content type="html" xml:base="https://blog.superails.com/google-onetap-oauth"><![CDATA[<p>Let’s implement google auth popup:</p>

<p><img src="/assets/images/google-one-touch-preview.png" alt="google-one-touch-preview" /></p>

<h3 id="1-google-oauth">1. Google oAuth</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"omniauth-google-oauth2"</span>
<span class="n">gem</span> <span class="s2">"omniauth-rails_csrf_protection"</span> <span class="c1"># for omniauth 2.0</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/devise.rb</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">omniauth</span> <span class="ss">:google_oauth2</span><span class="p">,</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">credentials</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="ss">:google_oauth2</span><span class="p">,</span> <span class="ss">:key</span><span class="p">),</span>
                  <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">credentials</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="ss">:google_oauth2</span><span class="p">,</span> <span class="ss">:secret</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/user.rb</span>
<span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">devise</span> <span class="ss">:invitable</span><span class="p">,</span> <span class="ss">:database_authenticatable</span><span class="p">,</span>
    <span class="ss">:rememberable</span><span class="p">,</span> <span class="ss">:validatable</span><span class="p">,</span>
    <span class="ss">:omniauthable</span><span class="p">,</span> <span class="ss">omniauth_providers: </span><span class="p">[</span> <span class="ss">:google_oauth2</span> <span class="p">]</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/user.rb</span>
  <span class="k">def</span> <span class="nf">from_omniauth</span><span class="p">(</span><span class="n">auth_payload</span><span class="p">)</span>
    <span class="n">data</span> <span class="o">=</span> <span class="n">auth_payload</span><span class="p">.</span><span class="nf">info</span>
    <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">email: </span><span class="n">data</span><span class="p">[</span><span class="s2">"email"</span><span class="p">]).</span><span class="nf">first_or_initialize</span> <span class="k">do</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span>
      <span class="n">user</span><span class="p">.</span><span class="nf">email</span> <span class="o">=</span> <span class="n">data</span><span class="p">[</span><span class="s2">"email"</span><span class="p">]</span>
      <span class="n">user</span><span class="p">.</span><span class="nf">password</span> <span class="o">=</span> <span class="no">Devise</span><span class="p">.</span><span class="nf">friendly_token</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">20</span><span class="p">]</span> <span class="k">if</span> <span class="n">user</span><span class="p">.</span><span class="nf">password</span><span class="p">.</span><span class="nf">blank?</span>
    <span class="k">end</span>

    <span class="n">user</span><span class="p">.</span><span class="nf">name</span> <span class="o">=</span> <span class="n">auth_payload</span><span class="p">.</span><span class="nf">info</span><span class="p">.</span><span class="nf">name</span>
    <span class="n">user</span><span class="p">.</span><span class="nf">image</span> <span class="o">=</span> <span class="n">auth_payload</span><span class="p">.</span><span class="nf">info</span><span class="p">.</span><span class="nf">image</span>
    <span class="n">user</span><span class="p">.</span><span class="nf">provider</span> <span class="o">=</span> <span class="n">auth_payload</span><span class="p">.</span><span class="nf">provider</span>
    <span class="n">user</span><span class="p">.</span><span class="nf">uid</span> <span class="o">=</span> <span class="n">auth_payload</span><span class="p">.</span><span class="nf">uid</span>
    <span class="n">user</span><span class="p">.</span><span class="nf">save</span>
    <span class="n">user</span>
  <span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/users/omniauth_callbacks_controller.rb</span>
<span class="k">class</span> <span class="nc">Users::OmniauthCallbacksController</span> <span class="o">&lt;</span> <span class="no">Devise</span><span class="o">::</span><span class="no">OmniauthCallbacksController</span>
  <span class="k">def</span> <span class="nf">google_oauth2</span>
    <span class="n">handle_auth</span> <span class="s2">"Google"</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">handle_auth</span><span class="p">(</span><span class="n">kind</span><span class="p">)</span>
    <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">from_omniauth</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="nf">env</span><span class="p">[</span><span class="s2">"omniauth.auth"</span><span class="p">])</span>
    <span class="k">if</span> <span class="n">user</span><span class="p">.</span><span class="nf">persisted?</span>
      <span class="n">flash</span><span class="p">[</span><span class="ss">:notice</span><span class="p">]</span> <span class="o">=</span> <span class="no">I18n</span><span class="p">.</span><span class="nf">t</span> <span class="s2">"devise.omniauth_callbacks.success"</span><span class="p">,</span> <span class="ss">kind: </span><span class="n">kind</span>
      <span class="n">sign_in_and_redirect</span> <span class="n">user</span><span class="p">,</span> <span class="ss">event: :authentication</span>
    <span class="k">else</span>
      <span class="n">session</span><span class="p">[</span><span class="s2">"devise.auth_data"</span><span class="p">]</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="nf">env</span><span class="p">[</span><span class="s2">"omniauth.auth"</span><span class="p">].</span><span class="nf">except</span><span class="p">(</span><span class="ss">:extra</span><span class="p">)</span>
      <span class="n">redirect_to</span> <span class="n">root_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="n">user</span><span class="p">.</span><span class="nf">errors</span><span class="p">.</span><span class="nf">full_messages</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">failure</span>
    <span class="n">redirect_to</span> <span class="n">root_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Failure. Please try again"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="sx">%= button_to "Sign in with Google", "/users/auth/google_oauth2", method: :post, data: { turbo: "false" } %&gt;
</span></code></pre></div></div>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/routes.rb
  devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks" }, skip: [ :sessions, :registrations ]
  devise_scope :user do
    delete "/users/sign_out", to: "devise/sessions#destroy", as: :destroy_user_session
  end
</code></pre></div></div>

<h3 id="2-google-one-tap-popup">2. Google One Tap popup</h3>

<p>First, allow JS origins for development &amp; production in the <a href="https://console.cloud.google.com/apis/dashboard">Google API Dashboard</a></p>

<p><img src="/assets/images/google-one-touch-authorize.png" alt="google-one-touch-authorize" /></p>

<p>In your OAuth Client ID settings, add:</p>
<ul>
  <li><strong>Authorized JavaScript origins</strong>: <code class="language-plaintext highlighter-rouge">http://localhost:3000</code>, <code class="language-plaintext highlighter-rouge">https://yourdomain.com</code></li>
  <li><strong>Authorized redirect URIs</strong>: <code class="language-plaintext highlighter-rouge">http://localhost:3000/google_onetap_callback</code>, <code class="language-plaintext highlighter-rouge">https://yourdomain.com/google_onetap_callback</code></li>
</ul>

<p>⚠️ Changes in Google Cloud Console can take <strong>5-30 minutes</strong> to propagate. If you see <code class="language-plaintext highlighter-rouge">The given origin is not allowed for the given client ID</code> in the browser console, wait and retry.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/routes.rb
  devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks" }, skip: [ :sessions, :registrations ]
   devise_scope :user do
    delete "/users/sign_out", to: "devise/sessions#destroy", as: :destroy_user_session
<span class="gi">+     post "/google_onetap_callback", to: "users/omniauth_callbacks#google_onetap", as: :google_onetap_callback
</span>   end
</code></pre></div></div>

<p>The One Tap partial. Render it on pages where you want the prompt (e.g. homepage):</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- app/views/shared/_google_onetap.html.erb --&gt;</span>
<span class="nt">&lt;</span><span class="err">%</span> <span class="na">if</span> <span class="na">Rails.application.credentials.dig</span><span class="err">(</span><span class="na">:google_oauth2</span><span class="err">,</span> <span class="na">:key</span><span class="err">).</span><span class="na">present</span><span class="err">?</span> <span class="err">&amp;&amp;</span> <span class="err">!</span><span class="na">user_signed_in</span><span class="err">?</span> <span class="err">%</span><span class="nt">&gt;</span>
  <span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://accounts.google.com/gsi/client"</span> <span class="na">async</span> <span class="na">defer</span><span class="nt">&gt;&lt;/script&gt;</span>
  <span class="nt">&lt;div</span>
    <span class="na">id=</span><span class="s">"g_id_onload"</span>
    <span class="na">data-client_id=</span><span class="s">"&lt;%= Rails.application.credentials.dig(:google_oauth2, :key) %&gt;"</span>
    <span class="na">data-login_uri=</span><span class="s">"&lt;%= google_onetap_callback_url %&gt;"</span>
    <span class="na">data-itp_support=</span><span class="s">"true"</span>
    <span class="na">data-context=</span><span class="s">"signin"</span>
  <span class="nt">&gt;&lt;/div&gt;</span>
<span class="nt">&lt;</span><span class="err">%</span> <span class="na">end</span> <span class="err">%</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Google POSTs a signed JWT to your callback. You need to verify it. Use the <code class="language-plaintext highlighter-rouge">jwt</code> gem (not <code class="language-plaintext highlighter-rouge">googleauth</code>, which has OpenSSL 3.x CRL issues on macOS):</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"jwt"</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/users/omniauth_callbacks_controller.rb</span>
<span class="k">class</span> <span class="nc">Users::OmniauthCallbacksController</span> <span class="o">&lt;</span> <span class="no">Devise</span><span class="o">::</span><span class="no">OmniauthCallbacksController</span>
  <span class="n">skip_before_action</span> <span class="ss">:verify_authenticity_token</span><span class="p">,</span> <span class="ss">only: :google_onetap</span>

  <span class="k">def</span> <span class="nf">google_onetap</span>
    <span class="k">unless</span> <span class="n">g_csrf_token_valid?</span>
      <span class="n">redirect_to</span> <span class="n">root_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Failure. Please try again"</span>
      <span class="k">return</span>
    <span class="k">end</span>

    <span class="n">payload</span> <span class="o">=</span> <span class="n">verify_google_id_token</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:credential</span><span class="p">])</span>
    <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">from_google_onetap</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">user</span><span class="p">.</span><span class="nf">persisted?</span>
      <span class="n">flash</span><span class="p">[</span><span class="ss">:notice</span><span class="p">]</span> <span class="o">=</span> <span class="no">I18n</span><span class="p">.</span><span class="nf">t</span> <span class="s2">"devise.omniauth_callbacks.success"</span><span class="p">,</span> <span class="ss">kind: </span><span class="s2">"Google"</span>
      <span class="n">sign_in_and_redirect</span> <span class="n">user</span><span class="p">,</span> <span class="ss">event: :authentication</span>
    <span class="k">else</span>
      <span class="n">redirect_to</span> <span class="n">root_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="n">user</span><span class="p">.</span><span class="nf">errors</span><span class="p">.</span><span class="nf">full_messages</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">rescue</span> <span class="no">JWT</span><span class="o">::</span><span class="no">DecodeError</span>
    <span class="n">redirect_to</span> <span class="n">root_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Failure. Please try again"</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="no">GOOGLE_CERTS_URL</span> <span class="o">=</span> <span class="s2">"https://www.googleapis.com/oauth2/v3/certs"</span>
  <span class="no">GOOGLE_ISSUERS</span> <span class="o">=</span> <span class="sx">%w[accounts.google.com https://accounts.google.com]</span><span class="p">.</span><span class="nf">freeze</span>

  <span class="k">def</span> <span class="nf">g_csrf_token_valid?</span>
    <span class="n">token</span> <span class="o">=</span> <span class="n">cookies</span><span class="p">[</span><span class="s2">"g_csrf_token"</span><span class="p">]</span>
    <span class="n">token</span><span class="p">.</span><span class="nf">present?</span> <span class="o">&amp;&amp;</span> <span class="n">token</span> <span class="o">==</span> <span class="n">params</span><span class="p">[</span><span class="s2">"g_csrf_token"</span><span class="p">]</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">verify_google_id_token</span><span class="p">(</span><span class="n">token</span><span class="p">)</span>
    <span class="n">jwks</span> <span class="o">=</span> <span class="n">fetch_google_jwks</span>
    <span class="n">client_id</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">credentials</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="ss">:google_oauth2</span><span class="p">,</span> <span class="ss">:key</span><span class="p">)</span>

    <span class="n">payload</span><span class="p">,</span> <span class="o">=</span> <span class="no">JWT</span><span class="p">.</span><span class="nf">decode</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="kp">nil</span><span class="p">,</span> <span class="kp">true</span><span class="p">,</span> <span class="p">{</span>
      <span class="ss">algorithms: </span><span class="p">[</span><span class="s2">"RS256"</span><span class="p">],</span>
      <span class="ss">jwks: </span><span class="n">jwks</span><span class="p">,</span>
      <span class="ss">iss: </span><span class="no">GOOGLE_ISSUERS</span><span class="p">,</span>
      <span class="ss">verify_iss: </span><span class="kp">true</span><span class="p">,</span>
      <span class="ss">aud: </span><span class="n">client_id</span><span class="p">,</span>
      <span class="ss">verify_aud: </span><span class="kp">true</span>
    <span class="p">})</span>

    <span class="n">payload</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">fetch_google_jwks</span>
    <span class="no">Rails</span><span class="p">.</span><span class="nf">cache</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"google_jwks"</span><span class="p">,</span> <span class="ss">expires_in: </span><span class="mi">1</span><span class="p">.</span><span class="nf">hour</span><span class="p">)</span> <span class="k">do</span>
      <span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="p">(</span><span class="no">GOOGLE_CERTS_URL</span><span class="p">)</span>
      <span class="n">http</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">uri</span><span class="p">.</span><span class="nf">host</span><span class="p">,</span> <span class="n">uri</span><span class="p">.</span><span class="nf">port</span><span class="p">)</span>
      <span class="n">http</span><span class="p">.</span><span class="nf">use_ssl</span> <span class="o">=</span> <span class="kp">true</span>
      <span class="n">http</span><span class="p">.</span><span class="nf">open_timeout</span> <span class="o">=</span> <span class="mi">5</span>
      <span class="n">http</span><span class="p">.</span><span class="nf">read_timeout</span> <span class="o">=</span> <span class="mi">5</span>
      <span class="n">response</span> <span class="o">=</span> <span class="n">http</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">uri</span><span class="p">.</span><span class="nf">path</span><span class="p">)</span>
      <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="nf">body</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The One Tap payload is different from the classic OAuth payload:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/user.rb</span>
<span class="k">def</span> <span class="nf">from_google_onetap</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>
  <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">email: </span><span class="n">payload</span><span class="p">[</span><span class="s2">"email"</span><span class="p">]).</span><span class="nf">first_or_initialize</span> <span class="k">do</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span>
    <span class="n">user</span><span class="p">.</span><span class="nf">email</span> <span class="o">=</span> <span class="n">payload</span><span class="p">[</span><span class="s2">"email"</span><span class="p">]</span>
    <span class="n">user</span><span class="p">.</span><span class="nf">password</span> <span class="o">=</span> <span class="no">Devise</span><span class="p">.</span><span class="nf">friendly_token</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">20</span><span class="p">]</span> <span class="k">if</span> <span class="n">user</span><span class="p">.</span><span class="nf">password</span><span class="p">.</span><span class="nf">blank?</span>
  <span class="k">end</span>

  <span class="n">user</span><span class="p">.</span><span class="nf">name</span> <span class="o">=</span> <span class="n">payload</span><span class="p">[</span><span class="s2">"name"</span><span class="p">]</span>
  <span class="n">user</span><span class="p">.</span><span class="nf">image</span> <span class="o">=</span> <span class="n">payload</span><span class="p">[</span><span class="s2">"picture"</span><span class="p">]</span>
  <span class="n">user</span><span class="p">.</span><span class="nf">provider</span> <span class="o">=</span> <span class="s2">"google_oauth2"</span>
  <span class="n">user</span><span class="p">.</span><span class="nf">uid</span> <span class="o">=</span> <span class="n">payload</span><span class="p">[</span><span class="s2">"sub"</span><span class="p">]</span>
  <span class="n">user</span><span class="p">.</span><span class="nf">save</span>
  <span class="n">user</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="gotchas">Gotchas</h3>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">skip_before_action :verify_authenticity_token</code></strong> is required for the <code class="language-plaintext highlighter-rouge">google_onetap</code> action because Google POSTs from their domain. Google’s own CSRF token (<code class="language-plaintext highlighter-rouge">g_csrf_token</code>) is validated instead.</li>
  <li><strong>Content blocker errors</strong> in console (<code class="language-plaintext highlighter-rouge">/gsi/log</code> blocked) are harmless — just Google’s telemetry being blocked by ad blockers.</li>
  <li><strong>OpenSSL 3.x CRL errors on macOS</strong>: If you hit <code class="language-plaintext highlighter-rouge">certificate verify failed (unable to get certificate CRL)</code> when fetching Google’s JWKS, set <code class="language-plaintext highlighter-rouge">cert_store.flags = 0</code> on the <code class="language-plaintext highlighter-rouge">Net::HTTP</code> connection to disable CRL checking while still verifying the cert chain.</li>
</ul>

<p>Inspired by <a href="https://patrickkarsh.medium.com/how-to-add-google-one-touch-authentication-to-a-ruby-on-rails-application-6ac8776c4190">patrickkarsh’s post</a>.</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="google" /><category term="oauth" /><category term="omniauth" /><summary type="html"><![CDATA[Let’s implement google auth popup:]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Deploying Rails 8 on Render.com</title><link href="https://blog.superails.com/rails-8-solid-postgresql-render-com-production" rel="alternate" type="text/html" title="Deploying Rails 8 on Render.com" /><published>2025-04-23T00:00:00+00:00</published><updated>2025-04-23T00:00:00+00:00</updated><id>https://blog.superails.com/rails-8-solid-postgresql-render-com-production</id><content type="html" xml:base="https://blog.superails.com/rails-8-solid-postgresql-render-com-production"><![CDATA[<p>I’ve been using Heroku since 2015 - that’s 10 years of experience. However, over the years, new Platform as a Service (PaaS) providers have emerged that offer better value and pricing than Heroku. Most notably - <a href="https://fnf.dev/4i8skLA">Render.com</a>.</p>

<h3 id="production-experience">Production Experience</h3>

<p><a href="https://superails.com">SupeRails.com</a> has been running on Render for two years. The architecture is simple:</p>

<p>The setup is straightforward:</p>

<ul>
  <li>A web server</li>
  <li>A PostgreSQL database</li>
  <li>A worker running ActiveJob with <a href="https://blog.superails.com/background-jobs-good-job">good_job</a> on PostgreSQL</li>
</ul>

<p><img src="/assets/render/render-sr-1-setup.png" alt="Render setup" /></p>

<p>Current infrastructure costs $21/month and handles our workload efficiently.</p>

<p><img src="/assets/render/render-sr-2-billing.png" alt="Render billing" /></p>

<p>When I was deploying the app to heroku, I used <code class="language-plaintext highlighter-rouge">jemallock</code> to decrease RAM usage and <code class="language-plaintext highlighter-rouge">headless chrome</code> to take screenshots in a headless browser:</p>

<p><img src="/assets/render/render-heroku-buildpacks.png" alt="Heroku buildpacks" /></p>

<p>To use <a href="https://community.render.com/t/how-to-use-jemalloc-in-ruby-web-service/1183"><code class="language-plaintext highlighter-rouge">jemallock</code></a> on Render, add ENV VAR:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>LD_PRELOAD: /usr/lib/x86_64-linux-gnu/libjemalloc.so
</code></pre></div></div>

<p>You can also add buildpacks if you use Docker for deployment.</p>

<h3 id="rails-8-no-paas">Rails 8 “No PaaS”</h3>

<p><img src="/assets/render/render-nopaas.png" alt="No PaaS" /></p>

<p>In his 2024 RailsWorld keynote, DHH introduced Kamal 2 - a deployment solution for running Rails apps on your own hardware instead of in the cloud. While this sounds interesting, in practice you’d still be renting hardware from providers like Hetzner. You’d also be responsible for managing servers, SSL certificates, multiple SQLite databases, and backups. This means more DevOps work and a new learning curve.</p>

<p>For me, ease of production deployment and maintenance has always been crucial. I’d rather pay a few extra dollars than spend time on DevOps tasks.</p>

<blockquote>
  <p>A problem that can be solved with money is not really a problem</p>
</blockquote>

<p><img src="/assets/render/render-kamal-maybe.png" alt="Kamal maybe" /></p>

<h3 id="rails-8-solid-cache-queue-cable">Rails 8 Solid Cache, Queue, Cable</h3>

<p>Rails 8 introduces Solid Queue, Solid Cache, and Solid Cable as built-in options for background jobs, caching, and real-time features. By default, these tools are configured to work with SQLite. If you want to use them with PostgreSQL on Render, check out my tutorial: <a href="/solid-trifecta-with-postgresql">Solid Trifecta with PostgreSQL</a></p>

<p>One of the coolest features of Solid Queue is its Puma adapter. When you set <code class="language-plaintext highlighter-rouge">ENV["SOLID_QUEUE_IN_PUMA"]</code> to <code class="language-plaintext highlighter-rouge">true</code>, jobs will run in the same process as your web app, reducing the resources needed!</p>

<h3 id="deploy-to-render">Deploy to Render</h3>

<p>While you can manually create and connect services (Web, Worker, Database, Redis, etc.), my preferred deployment method on Render is using a <code class="language-plaintext highlighter-rouge">render.yaml</code> <a href="https://fnf.dev/4i8skLA">blueprint</a>. This allows you to manage your entire Render deployment through a single file that’s committed to Git!</p>

<p>Here’s my <a href="https://github.com/yshmarov/moneygun/blob/main/render.yaml"><code class="language-plaintext highlighter-rouge">render.yaml</code></a> for deploying my Rails 8 Boilerplate app:</p>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># render.yaml</span>
<span class="na">databases</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">moneygun-db</span>
    <span class="na">region</span><span class="pi">:</span> <span class="s">frankfurt</span>
    <span class="na">ipAllowList</span><span class="pi">:</span> <span class="pi">[]</span> <span class="c1"># only allow internal connections</span>
    <span class="na">plan</span><span class="pi">:</span> <span class="s">free</span>
    <span class="c1"># plan: basic-256mb</span>
    <span class="c1"># diskSizeGB: 1</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">web</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">moneygun-web</span>
    <span class="na">runtime</span><span class="pi">:</span> <span class="s">ruby</span>
    <span class="na">plan</span><span class="pi">:</span> <span class="s">free</span>
    <span class="c1"># plan: starter</span>
    <span class="na">region</span><span class="pi">:</span> <span class="s">frankfurt</span>
    <span class="na">buildCommand</span><span class="pi">:</span> <span class="s">bundle install &amp;&amp; bundle exec rails assets:precompile &amp;&amp; bundle exec rails assets:clean &amp;&amp; bundle exec rails db:migrate</span>
    <span class="c1"># preDeployCommand: "bundle exec rails db:migrate" # preDeployCommand only available on paid instance types</span>
    <span class="c1">#  startCommand: bundle exec rails server</span>
    <span class="na">startCommand</span><span class="pi">:</span> <span class="s">./bin/rails server</span>
    <span class="na">healthCheckPath</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/up"</span>
    <span class="na">envVars</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">SENSIBLE_DEFAULTS</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s">enabled</span>
      <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">RAILS_ENV</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s">production</span>
      <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">RAILS_MASTER_KEY</span>
        <span class="na">sync</span><span class="pi">:</span> <span class="kc">false</span>
      <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">WEB_CONCURRENCY</span>
        <span class="na">value</span><span class="pi">:</span> <span class="m">2</span>
      <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">SOLID_QUEUE_IN_PUMA</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
      <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">DATABASE_URL</span>
        <span class="na">fromDatabase</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">moneygun-db</span>
          <span class="na">property</span><span class="pi">:</span> <span class="s">connectionString</span>
</code></pre></div></div>

<p>If you have a <code class="language-plaintext highlighter-rouge">render.yaml</code> file in your GitHub repository, you can add a one-click deploy button to your README!</p>

<p><img src="/assets/render/render-deploy-button.png" alt="Deploy button" /></p>

<p>Example script:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/yshmarov/moneygun)
</code></pre></div></div>

<p>More Render blueprint examples:</p>

<ul>
  <li><a href="https://github.com/render-examples/forem/blob/master/render.yaml">Blueprint for Advanced Rails app (Forem)</a></li>
  <li><a href="https://render.com/docs/deploy-rails-sidekiq">Sidekiq</a></li>
</ul>

<h3 id="render-pricing">Render Pricing</h3>

<p>If you’re just getting started, the free plan will be sufficient to deploy an app to production.</p>

<p>You get a free web server:</p>

<p><img src="/assets/render/render-free-web.png" alt="Free web" /></p>

<p>And <strong>one</strong> free database:</p>

<p><img src="/assets/render/render-free-db.png" alt="Free database" /></p>

<p>However, the free plan has limitations, and you won’t have access to features like <code class="language-plaintext highlighter-rouge">rails console</code>.</p>

<p>The basic plan ($6 + $7 = $13) would be enough to deploy a fully-featured web app with a database and Solid Queue worker to production.</p>

<p>🤠 That’s it! Time to move your apps from Heroku to <a href="https://fnf.dev/4i8skLA">Render</a>!</p>

<h3 id="render-vs-heroku">Render vs Heroku</h3>

<ul>
  <li>Render has Disks -&gt; you can persist data across deploys!</li>
  <li>Render has 100 minutes HTTP request timeout (vs 30 seconds on Heroku)</li>
  <li>Render is cheaper when scaling</li>
</ul>

<p>Here is a more detailed comparison of <a href="https://render.com/docs/render-vs-heroku-comparison">Render vs Heroku</a>.</p>

<h3 id="additional-resources">Additional Resources</h3>

<ul>
  <li><a href="https://www.reddit.com/r/rails/comments/1jwanqp/please_recommend_a_paas_that_is_not_heroku/">Reddit thread - many Rails devs choose Render</a></li>
  <li><a href="https://businessclasskit.com/docs/how-to-deploy-rails-sidekiq-render/">Render config for a Rails app</a></li>
</ul>

<hr />

<p>P.S. A big thank you to <a href="https://fnf.dev/4i8skLA">Render.com</a> for sponsoring this article! It gave me the opportunity to dive deeper into deploying Solid with PostgreSQL and explore Render.</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="rails" /><category term="solid_queue" /><category term="solid_cache" /><category term="solid_cable" /><summary type="html"><![CDATA[I’ve been using Heroku since 2015 - that’s 10 years of experience. However, over the years, new Platform as a Service (PaaS) providers have emerged that offer better value and pricing than Heroku. Most notably - Render.com.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Readonly Models without database</title><link href="https://blog.superails.com/active_hash_readonly_model_without_database" rel="alternate" type="text/html" title="Readonly Models without database" /><published>2025-04-22T00:00:00+00:00</published><updated>2025-04-22T00:00:00+00:00</updated><id>https://blog.superails.com/active_hash_readonly_model_without_database</id><content type="html" xml:base="https://blog.superails.com/active_hash_readonly_model_without_database"><![CDATA[<p>Use Ruby hashes as readonly datasources for ActiveRecord-like models.</p>

<p>Define data in YML</p>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/models/platforms.yml</span>
<span class="pi">-</span> <span class="na">id</span><span class="pi">:</span> <span class="s">instagram</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">Instagram</span>
  <span class="na">status</span><span class="pi">:</span> <span class="s">active</span>
  <span class="na">logo</span><span class="pi">:</span> <span class="s">instagram-logo.svg</span>
  <span class="na">url_pattern</span><span class="pi">:</span> <span class="s">https?://(?:www\.)?instagram\.com/</span>
<span class="pi">-</span> <span class="na">id</span><span class="pi">:</span> <span class="s">x</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">X</span>
  <span class="na">status</span><span class="pi">:</span> <span class="s">active</span>
  <span class="na">logo</span><span class="pi">:</span> <span class="s">x-logo.svg</span>
  <span class="na">url_pattern</span><span class="pi">:</span> <span class="s">https?://(?:www\.)?x\.com/|https?://(?:www\.)?twitter\.com/</span>
<span class="pi">-</span> <span class="na">id</span><span class="pi">:</span> <span class="s">youtube</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">YouTube</span>
  <span class="na">status</span><span class="pi">:</span> <span class="s">active</span>
  <span class="na">logo</span><span class="pi">:</span> <span class="s">youtube-logo.svg</span>
  <span class="na">url_pattern</span><span class="pi">:</span> <span class="s">https?://(?:www\.)?youtube\.com/</span>
</code></pre></div></div>

<p>Add <a href="https://github.com/active-hash/active_hash">gem active_hash</a></p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle add active_hash
</code></pre></div></div>

<p>Load the YML in a model:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/platform.rb</span>
<span class="k">class</span> <span class="nc">Platform</span> <span class="o">&lt;</span> <span class="no">ActiveHash</span><span class="o">::</span><span class="no">Base</span>
  <span class="kp">include</span> <span class="no">ActiveHash</span><span class="o">::</span><span class="no">Associations</span>

  <span class="nb">self</span><span class="p">.</span><span class="nf">data</span> <span class="o">=</span> <span class="no">YAML</span><span class="p">.</span><span class="nf">load_file</span><span class="p">(</span><span class="s2">"</span><span class="si">#{</span><span class="no">Rails</span><span class="p">.</span><span class="nf">root</span><span class="si">}</span><span class="s2">/config/models/platforms.yml"</span><span class="p">)</span>

  <span class="n">has_many</span> <span class="ss">:campaign_platforms</span><span class="p">,</span> <span class="ss">dependent: :restrict_with_error</span>
  <span class="c1"># has_many :campaign_platforms, dependent: :restrict_with_error, class_name: "Platform", foreign_key: :platform_id</span>
  <span class="n">has_many</span> <span class="ss">:campaigns</span><span class="p">,</span> <span class="ss">through: :campaign_platforms</span>

  <span class="k">def</span> <span class="nf">label_string</span>
    <span class="nb">name</span>
  <span class="k">end</span>

  <span class="n">scope</span> <span class="ss">:active</span><span class="p">,</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">where</span><span class="p">(</span><span class="ss">status: :active</span><span class="p">)</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Defign foreign key:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="n">add_column</span> <span class="ss">:campaign_platforms</span><span class="p">,</span> <span class="ss">:platform_id</span><span class="p">,</span> <span class="ss">:string</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">add_index</span> <span class="ss">:campaign_platforms</span><span class="p">,</span> <span class="ss">:platform_id</span>
</code></pre></div></div>

<p>Define associations</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/campaign_platform.rb</span>
<span class="k">class</span> <span class="nc">CampaignPlatform</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="kp">extend</span> <span class="no">ActiveHash</span><span class="o">::</span><span class="no">Associations</span><span class="o">::</span><span class="no">ActiveRecordExtensions</span>
  <span class="n">belongs_to</span> <span class="ss">:campaign</span>
  <span class="n">belongs_to</span> <span class="ss">:platform</span><span class="p">,</span> <span class="ss">class_name: </span><span class="s2">"Platform"</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="s2">"platform_id"</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/campaign.rb</span>
<span class="k">class</span> <span class="nc">Campaign</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="kp">extend</span> <span class="no">ActiveHash</span><span class="o">::</span><span class="no">Associations</span><span class="o">::</span><span class="no">ActiveRecordExtensions</span>
  <span class="n">has_many</span> <span class="ss">:campaign_platforms</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>
  <span class="n">has_many</span> <span class="ss">:platforms</span><span class="p">,</span> <span class="ss">through: :campaign_platforms</span>
</code></pre></div></div>

<p>For the data to be updated, you might have to restart the server.</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="rails" /><category term="active_hash" /><summary type="html"><![CDATA[Use Ruby hashes as readonly datasources for ActiveRecord-like models.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Use Solid Trifecta with one Postgresql database</title><link href="https://blog.superails.com/solid-trifecta-with-postgresql" rel="alternate" type="text/html" title="Use Solid Trifecta with one Postgresql database" /><published>2025-04-22T00:00:00+00:00</published><updated>2025-04-22T00:00:00+00:00</updated><id>https://blog.superails.com/solid-trifecta-with-postgresql</id><content type="html" xml:base="https://blog.superails.com/solid-trifecta-with-postgresql"><![CDATA[<p>I love <a href="https://blog.superails.com/background-jobs-good-job">gem good_job</a> for processing jobs in Postgresql. No need for Redis as a dependency!</p>

<p>However as of Rails 8 we have <a href="https://github.com/rails/solid_queue">Solid Queue</a>, <a href="https://github.com/rails/solid_cache">Solid Cache</a>, <a href="https://github.com/rails/solid_cable">Solid Cable</a> present by default in a new Rails app.</p>

<p>These tools are configured by default to work with SQLite. Not Postgresql.</p>

<p>Even if you generate a new Rails app via <code class="language-plaintext highlighter-rouge">rails new myapp -d=postgresql</code>.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>myapp % rails db:create
Created database <span class="s1">'myapp_development'</span>
Created database <span class="s1">'myapp_development_cache'</span>
Created database <span class="s1">'myapp_development_queue'</span>
Created database <span class="s1">'myapp_development_cable'</span>
Created database <span class="s1">'myapp_test'</span>
</code></pre></div></div>

<p>You most likely don’t want to pay for 4 Postgres databases in production!</p>

<h3 id="1-use-solid-queue--solid-cache-in-primary-postgres-database">1. Use Solid Queue &amp; Solid Cache in primary (Postgres) database</h3>

<p>Docs: <a href="https://github.com/rails/solid_queue?tab=readme-ov-file#single-database-configuration">Solid Queue: Single database configuration</a></p>

<p>First, ensure Solid tools are correctly installed</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails solid_cache:install
bin/rails solid_cable:install
bin/rails solid_queue:install
</code></pre></div></div>

<p>We don’t want separate schemas for each solid tool. Copy them as migrations to the main database</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails g migration AddSolidCable
rails g migration AddSolidCache
rails g migration AddSolidQueue
</code></pre></div></div>

<p>Remove solid databases from the condig file:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/database.yml
<span class="gd">-  cache:
-    &lt;&lt;: *primary_production
-    database: moneygun_production_cache
-    migrations_paths: db/cache_migrate
-  queue:
-    &lt;&lt;: *primary_production
-    database: moneygun_production_queue
-    migrations_paths: db/queue_migrate
-  cable:
-    &lt;&lt;: *primary_production
-    database: moneygun_production_cable
-    migrations_paths: db/cable_migrate
</span></code></pre></div></div>

<p>And use the primary database for cable, cache, queue</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/cable.yml
<span class="gd">-    writing: cable
</span><span class="gi">+    writing: primary
</span><span class="err">
</span># or
<span class="gd">-  connects_to:
-    database:
-      writing: primary
</span></code></pre></div></div>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/cache.yml
<span class="gd">-    writing: cache
</span><span class="gi">+    writing: primary
</span></code></pre></div></div>

<p>Configure solid_queue in development</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/environments/development.rb
<span class="gd">-  config.active_job.queue_adapter = :inline
</span><span class="gi">+  config.active_job.queue_adapter = :solid_queue
</span># we are not using a separate database
<span class="gd">-  config.solid_queue.connects_to = { database: { writing: :queue } }
</span></code></pre></div></div>

<p>and in production. Do not clutter the production logs with solid_queue</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/environments/production.rb
<span class="gd">-  config.solid_queue.connects_to = { database: { writing: :queue } }
</span><span class="gi">+  config.solid_queue.silence_polling = true
</span><span class="err">
</span># optional
<span class="p">config.cache_store = :solid_cache_store
</span></code></pre></div></div>

<p>Run solid queue in the same process as the web app via Puma:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/puma.rb
<span class="gd">- plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
</span><span class="gi">+ plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] || Rails.env.development?
</span></code></pre></div></div>

<p>OR run SolidQueue locally:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Procfile.dev
<span class="gi">+ worker: bin/rails solid_queue:start
</span></code></pre></div></div>

<h3 id="2-trigger-a-job">2. Trigger a job</h3>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails g job HelloWorld
rails c
HelloWorldJob.set<span class="o">(</span><span class="nb">wait</span>: 1.week<span class="o">)</span>.perform_later
</code></pre></div></div>

<h3 id="3-view-the-triggered-job-in-mission-control-dashboard">3. View the triggered job in Mission Control dashboard</h3>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle add mission_control-jobs
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/application.rb</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">mission_control</span><span class="p">.</span><span class="nf">jobs</span><span class="p">.</span><span class="nf">http_basic_auth_enabled</span> <span class="o">=</span> <span class="kp">false</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
  <span class="n">mount</span> <span class="no">MissionControl</span><span class="o">::</span><span class="no">Jobs</span><span class="o">::</span><span class="no">Engine</span><span class="p">,</span> <span class="ss">at: </span><span class="s2">"/jobs"</span>
</code></pre></div></div>

<p>Other resources:</p>

<ul>
  <li><a href="https://andyatkinson.com/solid-queue-mission-control-rails-postgresql">andyatkinson: Solid Queue</a></li>
  <li><a href="https://andyatkinson.com/solid-cache-rails-postgresql">andyatkinson: Solid Cache</a></li>
</ul>]]></content><author><name>Yaroslav Shmarov</name></author><category term="rails" /><category term="solid_queue" /><category term="solid_cache" /><category term="solid_cable" /><summary type="html"><![CDATA[I love gem good_job for processing jobs in Postgresql. No need for Redis as a dependency!]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AutoNumeric.js: The Best Currency Input Field for Rails</title><link href="https://blog.superails.com/money-input-field" rel="alternate" type="text/html" title="AutoNumeric.js: The Best Currency Input Field for Rails" /><published>2025-03-25T00:00:00+00:00</published><updated>2025-03-25T00:00:00+00:00</updated><id>https://blog.superails.com/money-input-field</id><content type="html" xml:base="https://blog.superails.com/money-input-field"><![CDATA[<p><a href="https://autonumeric.org">AutoNumeric.js</a> provides great formatting for currency/money input fields. I’ve used it in a few companies over the years.</p>

<p><img src="/assets/images/autonumeric.mov" alt="Autonumeric demo" /></p>

<p>Here’s how to implement it in your Rails application with Stimulus:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/importmap pin autonumeric
rails g stimulus autonumeric
</code></pre></div></div>

<p>The importmap pin might fail, so a valid import from an url would be</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/importmap.rb
<span class="gd">-pin "autonumeric"
</span><span class="gi">+pin "autonumeric", to: "https://ga.jspm.io/npm:autonumeric@4.6.0/dist/autoNumeric.min.js"
</span></code></pre></div></div>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/controllers/index.js</span>
<span class="k">import</span> <span class="nx">AutoNumeric</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">autonumeric</span><span class="dl">"</span><span class="p">;</span>
<span class="nx">application</span><span class="p">.</span><span class="nf">register</span><span class="p">(</span><span class="dl">"</span><span class="s2">autonumeric</span><span class="dl">"</span><span class="p">,</span> <span class="nx">AutoNumeric</span><span class="p">);</span>
</code></pre></div></div>

<p>The Stimulus controller:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">Controller</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@hotwired/stimulus</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">AutoNumeric</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">autonumeric</span><span class="dl">"</span><span class="p">;</span>

<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">Controller</span> <span class="p">{</span>
  <span class="kd">static</span> <span class="nx">values</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">min</span><span class="p">:</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="nb">Number</span><span class="p">,</span> <span class="na">default</span><span class="p">:</span> <span class="mi">0</span> <span class="p">},</span>
    <span class="na">max</span><span class="p">:</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="nb">Number</span><span class="p">,</span> <span class="na">default</span><span class="p">:</span> <span class="mf">999999.99</span> <span class="p">},</span>
    <span class="na">currency</span><span class="p">:</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="nb">String</span><span class="p">,</span> <span class="na">default</span><span class="p">:</span> <span class="dl">"</span><span class="s2">€</span><span class="dl">"</span> <span class="p">},</span>
  <span class="p">};</span>

  <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">autoNumericOptions</span> <span class="o">=</span> <span class="p">{</span>
      <span class="na">decimalCharacter</span><span class="p">:</span> <span class="dl">"</span><span class="s2">.</span><span class="dl">"</span><span class="p">,</span>
      <span class="na">decimalPlaces</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
      <span class="na">digitGroupSeparator</span><span class="p">:</span> <span class="dl">"</span><span class="s2">,</span><span class="dl">"</span><span class="p">,</span>
      <span class="na">minimumValue</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">minValue</span><span class="p">,</span>
      <span class="na">maximumValue</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">maxValue</span><span class="p">,</span>
      <span class="na">unformatOnSubmit</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
      <span class="na">currencySymbol</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">currencyValue</span><span class="p">,</span>
      <span class="na">currencySymbolPlacement</span><span class="p">:</span> <span class="dl">"</span><span class="s2">p</span><span class="dl">"</span><span class="p">,</span> <span class="c1">// 'p' for prefix</span>
      <span class="na">modifyValueOnWheel</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
    <span class="p">};</span>

    <span class="k">new</span> <span class="nc">AutoNumeric</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">element</span><span class="p">,</span> <span class="nx">autoNumericOptions</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Use in a rails text_field. Autonumeric requires you to use <code class="language-plaintext highlighter-rouge">text_field</code>, not <code class="language-plaintext highlighter-rouge">number_field</code>.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="sx">%= form.text_field :name,
                    data: { controller: "autonumeric",
                            autonumeric_currency_value: "€",
                            autonumeric_min_value: 0, autonumeric_max_value: 999999.99 },
                    class: "" %&gt;
</span></code></pre></div></div>

<p>This will:</p>

<ul>
  <li>Automatically formats numbers as currency (e.g., “$1,234.56”)</li>
  <li>Prevents invalid input</li>
  <li>Handles decimal places correctly</li>
  <li>Supports maximum value limits</li>
</ul>

<p>P.S. Please always store money in <code class="language-plaintext highlighter-rouge">bigint</code>. Not <code class="language-plaintext highlighter-rouge">integer</code> or <code class="language-plaintext highlighter-rouge">float</code>. Stripe stores amounts in <strong>cents</strong> and so should you!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="rails" /><category term="javascript" /><category term="stimulus" /><category term="autonumeric" /><summary type="html"><![CDATA[AutoNumeric.js provides great formatting for currency/money input fields. I’ve used it in a few companies over the years.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Style window.confirm() with Turbo</title><link href="https://blog.superails.com/style-confirm-modal" rel="alternate" type="text/html" title="Style window.confirm() with Turbo" /><published>2025-02-16T00:00:00+00:00</published><updated>2025-02-16T00:00:00+00:00</updated><id>https://blog.superails.com/style-confirm-modal</id><content type="html" xml:base="https://blog.superails.com/style-confirm-modal"><![CDATA[<p>In Rails we often use</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">button_to</span> <span class="s2">"Delete"</span><span class="p">,</span> <span class="n">my_path</span><span class="p">,</span> <span class="ss">method: :delete</span><span class="p">,</span> <span class="ss">data: </span><span class="p">{</span> <span class="ss">turbo_confirm: </span><span class="s2">"Are you sure?"</span> <span class="p">}</span>
</code></pre></div></div>

<p>That would open a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm">default browser confirm dialog</a>.</p>

<p>We can style it with TailwindCSS and a little JavaScript.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># app/javascript/application.js
<span class="p">import '@hotwired/turbo-rails'
import 'controllers'
</span><span class="gi">+import 'confirm'
</span></code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/importmap.rb</span>
<span class="n">pin</span> <span class="s2">"confirm"</span><span class="p">,</span> <span class="ss">to: </span><span class="s2">"confirm.js"</span>
</code></pre></div></div>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/confirm.js</span>
<span class="c1">// Custom TailwindCSS modals for confirm dialogs</span>
<span class="c1">//</span>
<span class="c1">// Example usage:</span>
<span class="c1">//</span>
<span class="c1">//   &lt;%= button_to "Delete", my_path, method: :delete, form: {</span>
<span class="c1">//     data: {</span>
<span class="c1">//       turbo_confirm: "Are you sure?",</span>
<span class="c1">//       turbo_confirm_description: "This will delete your record. Enter the record name to confirm.",</span>
<span class="c1">//       turbo_confirm_text: "record name"</span>
<span class="c1">//     }</span>
<span class="c1">//   } %&gt;</span>
<span class="kd">function</span> <span class="nf">insertConfirmModal</span><span class="p">(</span><span class="nx">message</span><span class="p">,</span> <span class="nx">element</span><span class="p">,</span> <span class="nx">button</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="nx">confirmInput</span> <span class="o">=</span> <span class="dl">""</span><span class="p">;</span>

  <span class="c1">// button is nil if using link_to with data-turbo-confirm</span>
  <span class="kd">let</span> <span class="nx">confirmText</span> <span class="o">=</span>
    <span class="nx">button</span><span class="p">?.</span><span class="nx">dataset</span><span class="p">?.</span><span class="nx">turboConfirmText</span> <span class="o">||</span> <span class="nx">element</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">turboConfirmText</span><span class="p">;</span>
  <span class="kd">let</span> <span class="nx">description</span> <span class="o">=</span>
    <span class="nx">button</span><span class="p">?.</span><span class="nx">dataset</span><span class="p">?.</span><span class="nx">turboConfirmDescription</span> <span class="o">||</span>
    <span class="nx">element</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">turboConfirmDescription</span> <span class="o">||</span>
    <span class="dl">""</span><span class="p">;</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">confirmText</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">confirmInput</span> <span class="o">=</span> <span class="s2">`&lt;input type="text" class="mt-4 form-control" data-behavior="confirm-text" /&gt;`</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="kd">let</span> <span class="nx">id</span> <span class="o">=</span> <span class="s2">`confirm-modal-</span><span class="p">${</span><span class="k">new</span> <span class="nc">Date</span><span class="p">().</span><span class="nf">getTime</span><span class="p">()}</span><span class="s2">`</span><span class="p">;</span>

  <span class="kd">let</span> <span class="nx">content</span> <span class="o">=</span> <span class="s2">`
      &lt;dialog id="</span><span class="p">${</span><span class="nx">id</span><span class="p">}</span><span class="s2">" class="modal rounded-lg max-w-md w-full backdrop:backdrop-blur-sm backdrop:bg-black/50"&gt;
        &lt;form method="dialog"&gt;
          &lt;div class="bg-white mx-auto rounded shadow p-6 max-w-md "&gt;
            &lt;h5 class="text-lg"&gt;</span><span class="p">${</span><span class="nx">message</span><span class="p">}</span><span class="s2">&lt;/h5&gt;
            &lt;p class="mt-2 text-sm text-gray-700 "&gt;</span><span class="p">${</span><span class="nx">description</span><span class="p">}</span><span class="s2">&lt;/p&gt;
  
            </span><span class="p">${</span><span class="nx">confirmInput</span><span class="p">}</span><span class="s2">
  
            &lt;div class="flex justify-end items-center flex-wrap gap-2 mt-4"&gt;
              &lt;button value="cancel" class="btn btn-secondary"&gt;Cancel&lt;/button&gt;
              &lt;button value="confirm" class="btn btn-danger"&gt;Confirm&lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/form&gt;
      &lt;/dialog&gt;
    `</span><span class="p">;</span>

  <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nf">insertAdjacentHTML</span><span class="p">(</span><span class="dl">"</span><span class="s2">beforeend</span><span class="dl">"</span><span class="p">,</span> <span class="nx">content</span><span class="p">);</span>
  <span class="kd">let</span> <span class="nx">modal</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="nx">id</span><span class="p">);</span>

  <span class="c1">// Focus on the first button in the modal after rendering</span>
  <span class="nx">modal</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">button</span><span class="dl">"</span><span class="p">).</span><span class="nf">focus</span><span class="p">();</span>

  <span class="c1">// Disable commit button until the value matches confirmText</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">confirmText</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="nx">commitButton</span> <span class="o">=</span> <span class="nx">modal</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">[value='confirm']</span><span class="dl">"</span><span class="p">);</span>
    <span class="nx">commitButton</span><span class="p">.</span><span class="nx">disabled</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
    <span class="nx">modal</span>
      <span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">input[data-behavior='confirm-text']</span><span class="dl">"</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">input</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nx">commitButton</span><span class="p">.</span><span class="nx">disabled</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">value</span> <span class="o">!=</span> <span class="nx">confirmText</span><span class="p">;</span>
      <span class="p">});</span>
  <span class="p">}</span>

  <span class="k">return</span> <span class="nx">modal</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1">// Replace deprecated Turbo.setConfirmMethod with new config approach</span>
<span class="nx">Turbo</span><span class="p">.</span><span class="nx">config</span><span class="p">.</span><span class="nx">forms</span><span class="p">.</span><span class="nx">confirm</span> <span class="o">=</span> <span class="p">(</span><span class="nx">message</span><span class="p">,</span> <span class="nx">element</span><span class="p">,</span> <span class="nx">button</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="nx">dialog</span> <span class="o">=</span> <span class="nf">insertConfirmModal</span><span class="p">(</span><span class="nx">message</span><span class="p">,</span> <span class="nx">element</span><span class="p">,</span> <span class="nx">button</span><span class="p">);</span>
  <span class="nx">dialog</span><span class="p">.</span><span class="nf">showModal</span><span class="p">();</span>

  <span class="k">return</span> <span class="k">new</span> <span class="nc">Promise</span><span class="p">((</span><span class="nx">resolve</span><span class="p">,</span> <span class="nx">reject</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">dialog</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span>
      <span class="dl">"</span><span class="s2">close</span><span class="dl">"</span><span class="p">,</span>
      <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nf">resolve</span><span class="p">(</span><span class="nx">dialog</span><span class="p">.</span><span class="nx">returnValue</span> <span class="o">==</span> <span class="dl">"</span><span class="s2">confirm</span><span class="dl">"</span><span class="p">);</span>
      <span class="p">},</span>
      <span class="p">{</span> <span class="na">once</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span>
    <span class="p">);</span>
  <span class="p">});</span>
<span class="p">};</span>
</code></pre></div></div>

<p>Now your confirm dialogs will be sexy!</p>

<p>Inspired by <a href="https://dev.to/railsdesigner/custom-confirm-dialog-for-turbo-and-rails-3n96">Rails Designer</a>, Gorails’ “Custom Turbo Confirm Modals with Hotwire in Rails” &amp; others.</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="javascript" /><category term="turbo" /><summary type="html"><![CDATA[In Rails we often use]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Conditinally include Avo Pro in your Gemfile</title><link href="https://blog.superails.com/avo-pro-upgrade" rel="alternate" type="text/html" title="Conditinally include Avo Pro in your Gemfile" /><published>2025-02-13T00:00:00+00:00</published><updated>2025-02-13T00:00:00+00:00</updated><id>https://blog.superails.com/avo-pro-upgrade</id><content type="html" xml:base="https://blog.superails.com/avo-pro-upgrade"><![CDATA[<p>So the other day I purchased an Avo Pro license and needed to update my app to use it.</p>

<p>First, I followed the <a href="https://docs.avohq.io/3.0/gem-server-authentication.html">official upgrade guide</a>.</p>

<p>Here’s how to install Avo Pro without requiring it for all developers on the team.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"avo"</span><span class="p">,</span> <span class="s2">"&gt;= 3.4.1"</span>
<span class="n">gem</span> <span class="s2">"pundit"</span>
<span class="n">group</span> <span class="ss">:avo</span><span class="p">,</span> <span class="ss">optional: </span><span class="kp">true</span> <span class="k">do</span>
  <span class="n">source</span> <span class="s2">"https://packager.dev/avo-hq/"</span> <span class="k">do</span>
    <span class="n">gem</span> <span class="s2">"avo-pro"</span>
  <span class="k">end</span>
<span class="k">end</span>
<span class="n">group</span> <span class="ss">:development</span><span class="p">,</span> <span class="ss">:test</span> <span class="k">do</span>
  <span class="n">gem</span> <span class="s2">"dotenv"</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># .env</span>
<span class="nv">RAILS_GROUPS</span><span class="o">=</span>avo
<span class="nv">BUNDLE_WITH</span><span class="o">=</span>avo
<span class="nv">BUNDLE_PACKAGER__DEV</span><span class="o">=</span>foobar
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/avo.rb</span>
<span class="no">Avo</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">license_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">[</span><span class="s2">"AVO_LICENSE_KEY"</span><span class="p">]</span>
<span class="k">end</span>

<span class="k">unless</span> <span class="k">defined?</span><span class="p">(</span><span class="no">Avo</span><span class="o">::</span><span class="no">Pro</span><span class="p">)</span>
  <span class="no">Rails</span><span class="p">.</span><span class="nf">autoloaders</span><span class="p">.</span><span class="nf">main</span><span class="p">.</span><span class="nf">ignore</span><span class="p">(</span><span class="no">Rails</span><span class="p">.</span><span class="nf">root</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"app/avo/cards"</span><span class="p">))</span>
  <span class="no">Rails</span><span class="p">.</span><span class="nf">autoloaders</span><span class="p">.</span><span class="nf">main</span><span class="p">.</span><span class="nf">ignore</span><span class="p">(</span><span class="no">Rails</span><span class="p">.</span><span class="nf">root</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"app/avo/dashboards"</span><span class="p">))</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="local-development">Local Development</h3>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">BUNDLE_PACKAGER__DEV</span><span class="o">=</span>foobar bundle <span class="nb">install
</span><span class="nv">BUNDLE_PACKAGER__DEV</span><span class="o">=</span>foobar bundle update
<span class="nb">export </span><span class="nv">BUNDLE_PACKAGER__DEV</span><span class="o">=</span>foobar
<span class="c"># Or set globally:</span>
bundle config <span class="nb">set</span> <span class="nt">--global</span> https://packager.dev/avo-hq/ foobar
</code></pre></div></div>

<h3 id="github-ci">GitHub CI</h3>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">env</span><span class="pi">:</span>
  <span class="na">RAILS_ENV</span><span class="pi">:</span> <span class="s">test</span>
  <span class="na">RAILS_MASTER_KEY</span><span class="pi">:</span> <span class="s">$</span>
  <span class="na">DATABASE_URL</span><span class="pi">:</span> <span class="s2">"</span><span class="s">postgres://rails:password@localhost:5432/rails_test"</span>
  <span class="na">BUNDLE_PACKAGER__DEV</span><span class="pi">:</span> <span class="s">$</span>
</code></pre></div></div>

<h3 id="production-environment-variables">Production Environment Variables</h3>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">RAILS_GROUPS</span><span class="o">=</span>avo
<span class="nv">BUNDLE_WITH</span><span class="o">=</span>avo
<span class="nv">BUNDLE_PACKAGER__DEV</span><span class="o">=</span>foobar
<span class="nv">AVO_LICENSE_KEY</span><span class="o">=</span>bizzbazz
</code></pre></div></div>

<p>Run rails server with Avo Pro directly</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">RAILS_GROUPS</span><span class="o">=</span>avo <span class="nv">BUNDLE_WITH</span><span class="o">=</span>avo rails s
</code></pre></div></div>]]></content><author><name>Yaroslav Shmarov</name></author><category term="avo" /><summary type="html"><![CDATA[So the other day I purchased an Avo Pro license and needed to update my app to use it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Random Pagination</title><link href="https://blog.superails.com/random-pagination" rel="alternate" type="text/html" title="Random Pagination" /><published>2025-02-12T00:00:00+00:00</published><updated>2025-02-12T00:00:00+00:00</updated><id>https://blog.superails.com/random-pagination</id><content type="html" xml:base="https://blog.superails.com/random-pagination"><![CDATA[<p>Sometimes you want to display records in a random order, but also paginate them.</p>

<p><code class="language-plaintext highlighter-rouge">order("RANDOM()")</code> can return duplicates on next pages.</p>

<p>My solution is to set a random seed, and then order by a random value set with that seed.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="k">def</span> <span class="nf">index</span>
    <span class="n">cookies</span><span class="p">[</span><span class="ss">:seed</span><span class="p">]</span> <span class="o">||=</span> <span class="no">SecureRandom</span><span class="p">.</span><span class="nf">random_number</span>
    <span class="vi">@posts</span> <span class="o">=</span> <span class="no">Post</span><span class="p">.</span><span class="nf">order</span><span class="p">(</span><span class="no">Arel</span><span class="p">.</span><span class="nf">sql</span><span class="p">(</span><span class="s2">"random()"</span><span class="p">)).</span><span class="nf">from</span><span class="p">(</span><span class="s2">"(SELECT setseed(</span><span class="si">#{</span><span class="n">cookies</span><span class="p">[</span><span class="ss">:seed</span><span class="p">]</span><span class="si">}</span><span class="s2">)) as seed_setup, posts"</span><span class="p">)</span>
    <span class="vi">@pagy</span><span class="p">,</span> <span class="vi">@posts</span> <span class="o">=</span> <span class="n">pagy_countless</span><span class="p">(</span><span class="vi">@posts</span><span class="p">,</span> <span class="ss">items: </span><span class="mi">24</span><span class="p">)</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>This will return a random order of posts, and the same order on each page.</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="pagination" /><category term="pagy" /><summary type="html"><![CDATA[Sometimes you want to display records in a random order, but also paginate them.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">2025 is the year of Markdown. Avo built Marksmith Markdown editor for Rails</title><link href="https://blog.superails.com/avo-marksmith-markdown-editor" rel="alternate" type="text/html" title="2025 is the year of Markdown. Avo built Marksmith Markdown editor for Rails" /><published>2025-02-06T00:00:00+00:00</published><updated>2025-02-06T00:00:00+00:00</updated><id>https://blog.superails.com/avo-marksmith-markdown-editor</id><content type="html" xml:base="https://blog.superails.com/avo-marksmith-markdown-editor"><![CDATA[<h3 id="the-age-of-markdown">The age of Markdown</h3>

<p>In late 2024, Microsoft released <a href="https://github.com/microsoft/markitdown">microsoft/markitdown</a>, a tool for converting files and office documents to Markdown.</p>

<p>PlanetScale uses MARKDOWN for its’ marketing pages.</p>

<p><img src="/assets/images/prefer-markdown.png" alt="planetscale uses markdown for marketing pages" /></p>

<p>Also my friend <a href="https://x.com/jeremysmithco">Jeremy Smith</a> built <a href="https://liminal.forum">Liminal Forum</a> that features a custom markdown editor.</p>

<p><img src="/assets/images/1-marksmith-liminal.png" alt="marksmith-liminal" /></p>

<p>At Rails World 2024 DHH teased <strong>House (MD)</strong>, a new <strong>markdown editor</strong> for Rails.</p>

<p><img src="/assets/images/2-marksmith-dhh-house-md.png" alt="marksmith-dhh-house-md" /></p>

<p>Sneek peak:</p>

<p><img src="/assets/images/3-marksmith-dhh-house-md-preview.png" alt="marksmith-dhh-house-md-preview" /></p>

<h3 id="marksmith-by-avo">Marksmith by Avo</h3>

<p>Nobody knows when House (MD) will actually be released, but in the meantime Avo built <a href="https://github.com/avo-hq/marksmith">Marksmith</a>, a markdown editor for Rails.</p>

<p><img src="/assets/images/4-marksmith-announcement.png" alt="marksmith-announcement" /></p>

<p>I plan to integrate it into <a href="https://superails.com">SupeRails</a> soon.</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="avo" /><summary type="html"><![CDATA[The age of Markdown]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Monaco Mareterra</title><link href="https://blog.superails.com/monaco-mareterra" rel="alternate" type="text/html" title="Monaco Mareterra" /><published>2025-02-06T00:00:00+00:00</published><updated>2025-02-06T00:00:00+00:00</updated><id>https://blog.superails.com/monaco-mareterra</id><content type="html" xml:base="https://blog.superails.com/monaco-mareterra"><![CDATA[<p>In the 18th century, Monaco sold 90% of its land to France.</p>

<p><img src="/assets/images/1-monaco-lost-land.png" alt="monaco-lost-land" /></p>

<p>Monaco has one of the highest population densities in the world.</p>

<p><img src="/assets/images/2-monaco-population-density.png" alt="monaco-population-density" /></p>

<p>The cost per square meter is one of the highest in the world.</p>

<p><img src="/assets/images/3-monaco-cost-per-square.png" alt="monaco-cost-per-square" /></p>

<p>Over the years, Monaco has reclaimed land from the sea.</p>

<p><img src="/assets/images/4-monaco-land-reclamation.png" alt="monaco-land-reclamation" /></p>

<p>The new district of Mareterra is being built on reclaimed land.</p>

<p><img src="/assets/images/5-monaco-mareterra-new-district.png" alt="monaco-mareterra-new-district" /></p>

<p>The new district of Mareterra is nearly built.</p>

<p><img src="/assets/images/6-monaco-mareterra-nearly-built.png" alt="monaco-mareterra-nearly-built" /></p>

<p><a href="https://www.youtube.com/watch?v=AbsFYe2xOx8">Source</a></p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="monaco" /><summary type="html"><![CDATA[In the 18th century, Monaco sold 90% of its land to France.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Get Youtube video thumbnail image</title><link href="https://blog.superails.com/oembed" rel="alternate" type="text/html" title="Get Youtube video thumbnail image" /><published>2025-01-24T00:00:00+00:00</published><updated>2025-01-24T00:00:00+00:00</updated><id>https://blog.superails.com/oembed</id><content type="html" xml:base="https://blog.superails.com/oembed"><![CDATA[<h4 id="paste-youtube-vimeo-or-wistia-video-url-to-get-the-thumbnail-image-and-oembed-data">Paste Youtube, Vimeo or Wistia video url to get the thumbnail image and oembed data</h4>

<div id="video-preview-container">
  <div>
    <input type="text" style="width: 100%;" value="https://www.youtube.com/watch?v=vS-F1PlLyTk" class="video-input" placeholder="Enter video URL (YouTube, Vimeo, or Wistia)" />
    <button onclick="videoPreview.fetchThumbnail()">
      Get video data
    </button>
  </div>
  <br />
  Video thumbnail:
  <div class="video-thumbnail-output"></div>
  <br />
  Makes request to:
  <div class="video-url-output"></div>
  <br />
  Result:
  <pre class="video-data-output"></pre>
</div>

<h4 id="how-it-works">How it works</h4>

<p>I often google “get youtube video thumbnail”.</p>

<p><a href="https://oembed.com/">Oembed</a> let’s you get metadata and embed code for a media asset.</p>

<p>If a web page has <code class="language-plaintext highlighter-rouge">&lt;link rel="alternate" type="application/json+oembed" href="..."&gt;</code>, it means that the page supports oembed. The href is the oembed url. The first part of the url is the oembed API endpoint.</p>

<p>For example</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"alternate"</span> <span class="na">type=</span><span class="s">"application/json+oembed"</span> <span class="na">href=</span><span class="s">"https://www.youtube.com/oembed?format=json&amp;url=https://www.youtube.com/watch?v=vS-F1PlLyTk"</span> <span class="na">title=</span><span class="s">"Typesense search with Ruby on Rails #225"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>means that the <strong>Youtube</strong> API endpoint is</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://www.youtube.com/oembed?format=json&amp;url=
</code></pre></div></div>

<p><strong>Vimeo</strong> (example url: <code class="language-plaintext highlighter-rouge">https://vimeo.com/158115405</code>)</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://vimeo.com/api/oembed.json?url=
</code></pre></div></div>

<p><strong>Wistia</strong> (example url: <code class="language-plaintext highlighter-rouge">https://clickfunnels-28.wistia.com/medias/661x8p4j6u</code>)</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://fast.wistia.com/oembed?url=
</code></pre></div></div>

<p>At the end of the API endpoint append the url to the video to get the oembed data.</p>

<script>
class VideoThumbnailPreview {
  constructor(container) {
    this.container = container;
    this.input = container.querySelector('.video-input');
    this.thumbnailOutput = container.querySelector('.video-thumbnail-output');
    this.dataOutput = container.querySelector('.video-data-output');
    this.urlOutput = container.querySelector('.video-url-output');
    
    // Bind event listeners
    this.input.addEventListener('input', () => this.fetchThumbnail());
    
    // Initial fetch if there's a value
    this.fetchThumbnail();
  }

  async fetchThumbnail() {
    const url = this.input.value;
    if (!url) {
      this.clearOutput();
      return;
    }

    const videoProvider = this.detectVideoProvider(url);
    if (!videoProvider) {
      this.clearOutput();
      return;
    }

    try {
      const data = await this.fetchOembedData(url, videoProvider);
      if (data) {
        this.thumbnailOutput.innerHTML = `<img src="${data.thumbnail_url}" alt="Video thumbnail">`;
        this.dataOutput.textContent = JSON.stringify(data, null, 2);
        this.urlOutput.textContent = url;
      } else {
        this.clearOutput();
      }
    } catch (error) {
      this.clearOutput();
    }
  }

  clearOutput() {
    this.thumbnailOutput.innerHTML = '';
    this.dataOutput.innerHTML = '';
  }

  detectVideoProvider(url) {
    if (url.match(/youtu/)) return 'youtube';
    if (url.match(/vimeo/)) return 'vimeo';
    if (url.match(/wistia/)) return 'wistia';
    return null;
  }

  async fetchOembedData(url, provider) {
    const endpoints = {
      youtube: `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
      vimeo: `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(url)}`,
      wistia: `https://fast.wistia.com/oembed?url=${encodeURIComponent(url)}&format=json`
    };

    const endpoint = endpoints[provider];
    if (!endpoint) return null;

    const response = await fetch(endpoint);
    if (!response.ok) return null;

    const data = await response.json();
    return data;
  }
}

document.addEventListener('DOMContentLoaded', () => {
  const container = document.querySelector('#video-preview-container');
  window.videoPreview = new VideoThumbnailPreview(container);
});
</script>

<h4 id="stimulusjs-implementation">StimulusJS implementation</h4>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// video_thumbnail_preview_controller.js</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Controller</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@hotwired/stimulus</span><span class="dl">'</span>

<span class="c1">// &lt;div data-controller="video-thumbnail-preview"&gt;</span>
<span class="c1">//   &lt;input</span>
<span class="c1">//     data-video-thumbnail-preview-target="input"</span>
<span class="c1">//     type="text"</span>
<span class="c1">//     data-action="input-&gt;video-thumbnail-preview#fetchThumbnail"</span>
<span class="c1">//     value="https://www.youtube.com/watch?v=RNaaODDtTLw"</span>
<span class="c1">//   &gt;</span>
<span class="c1">//   &lt;div data-video-thumbnail-preview-target="output"&gt;&lt;/div&gt;</span>
<span class="c1">// &lt;/div&gt;</span>

<span class="c1">// public vimeo video:</span>
<span class="c1">// https://vimeo.com/158115405</span>
<span class="c1">// https://vimeo.com/api/oembed.json?url=https://vimeo.com/158115405</span>

<span class="c1">// public youtube video:</span>
<span class="c1">// www.youtube.com/watch?v=944lk4JAdyg</span>
<span class="c1">// https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=944lk4JAdyg</span>

<span class="c1">// https://docs.wistia.com/docs/wistia-and-oembed</span>
<span class="c1">// public wistia video:</span>
<span class="c1">// https://clickfunnels-28.wistia.com/medias/661x8p4j6u</span>
<span class="c1">// https://fast.wistia.com/oembed?url=https://clickfunnels-28.wistia.com/medias/661x8p4j6u</span>

<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">Controller</span> <span class="p">{</span>
  <span class="kd">static</span> <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="dl">'</span><span class="s1">input</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">output</span><span class="dl">'</span><span class="p">]</span>

  <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">fetchThumbnail</span><span class="p">()</span>
  <span class="p">}</span>

  <span class="nf">outputTargetConnected</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">fetchThumbnail</span><span class="p">()</span>
  <span class="p">}</span>

  <span class="nf">inputTargetConnected</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">fetchThumbnail</span><span class="p">()</span>
  <span class="p">}</span>

  <span class="nf">inputTargetChanged</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">fetchThumbnail</span><span class="p">()</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nf">fetchThumbnail</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">inputTarget</span><span class="p">.</span><span class="nx">value</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">url</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">this</span><span class="p">.</span><span class="nf">clearOutputs</span><span class="p">()</span>
      <span class="k">return</span>
    <span class="p">}</span>

    <span class="kd">const</span> <span class="nx">videoProvider</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nf">detectVideoProvider</span><span class="p">(</span><span class="nx">url</span><span class="p">)</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">videoProvider</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">this</span><span class="p">.</span><span class="nf">clearOutputs</span><span class="p">()</span>
      <span class="k">return</span>
    <span class="p">}</span>

    <span class="k">try</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">thumbnailUrl</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nf">fetchOembedThumbnail</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">videoProvider</span><span class="p">)</span>
      <span class="k">if </span><span class="p">(</span><span class="nx">thumbnailUrl</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">this</span><span class="p">.</span><span class="nx">outputTargets</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">target</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
          <span class="nx">target</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="s2">`&lt;img src="</span><span class="p">${</span><span class="nx">thumbnailUrl</span><span class="p">}</span><span class="s2">" alt="Video thumbnail"&gt;`</span>
        <span class="p">})</span>
      <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="k">this</span><span class="p">.</span><span class="nf">clearOutputs</span><span class="p">()</span>
      <span class="p">}</span>
    <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">this</span><span class="p">.</span><span class="nf">clearOutputs</span><span class="p">()</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nf">clearOutputs</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">outputTargets</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">target</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nx">target</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="dl">''</span>
    <span class="p">})</span>
  <span class="p">}</span>

  <span class="nf">detectVideoProvider</span><span class="p">(</span><span class="nx">url</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">url</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/youtu/</span><span class="p">))</span> <span class="k">return</span> <span class="dl">'</span><span class="s1">youtube</span><span class="dl">'</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">url</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/vimeo/</span><span class="p">))</span> <span class="k">return</span> <span class="dl">'</span><span class="s1">vimeo</span><span class="dl">'</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">url</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/wistia/</span><span class="p">))</span> <span class="k">return</span> <span class="dl">'</span><span class="s1">wistia</span><span class="dl">'</span>
    <span class="k">return</span> <span class="kc">null</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nf">fetchOembedThumbnail</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">provider</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">endpoints</span> <span class="o">=</span> <span class="p">{</span>
      <span class="na">youtube</span><span class="p">:</span> <span class="s2">`https://www.youtube.com/oembed?url=</span><span class="p">${</span><span class="nf">encodeURIComponent</span><span class="p">(</span>
        <span class="nx">url</span>
      <span class="p">)}</span><span class="s2">&amp;format=json`</span><span class="p">,</span>
      <span class="na">vimeo</span><span class="p">:</span> <span class="s2">`https://vimeo.com/api/oembed.json?url=</span><span class="p">${</span><span class="nf">encodeURIComponent</span><span class="p">(</span><span class="nx">url</span><span class="p">)}</span><span class="s2">`</span><span class="p">,</span>
      <span class="na">wistia</span><span class="p">:</span> <span class="s2">`https://fast.wistia.com/oembed?url=</span><span class="p">${</span><span class="nf">encodeURIComponent</span><span class="p">(</span>
        <span class="nx">url</span>
      <span class="p">)}</span><span class="s2">&amp;format=json`</span>
    <span class="p">}</span>

    <span class="kd">const</span> <span class="nx">endpoint</span> <span class="o">=</span> <span class="nx">endpoints</span><span class="p">[</span><span class="nx">provider</span><span class="p">]</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">endpoint</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span>

    <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="nx">endpoint</span><span class="p">)</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span>

    <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nf">json</span><span class="p">()</span>
    <span class="k">return</span> <span class="nx">data</span><span class="p">.</span><span class="nx">thumbnail_url</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That’s it!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="oembed" /><summary type="html"><![CDATA[Paste Youtube, Vimeo or Wistia video url to get the thumbnail image and oembed data]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Typesense search in a Rails app</title><link href="https://blog.superails.com/typesense-rails" rel="alternate" type="text/html" title="Typesense search in a Rails app" /><published>2025-01-22T00:00:00+00:00</published><updated>2025-01-22T00:00:00+00:00</updated><id>https://blog.superails.com/typesense-rails</id><content type="html" xml:base="https://blog.superails.com/typesense-rails"><![CDATA[<p>If you want to search millions of records by multiple attributes, disregard typos like “JSON”/”Jason”, it makes sense to integrate separate <strong>search server</strong>, rather than overloading your Postgres database.</p>

<p>Popular search providers are <a href="https://typesense.org">Typesense</a>, ElasticSearch, Algolia, and MeiliSearch.</p>

<p>Typesense is <a href="https://fnf.dev/3ZIAjId">fully open source</a>.</p>

<p>Typesense is very easy to install and run locally.</p>

<p>For production, you can deploy on your own servers, or on <a href="https://cloud.typesense.org">Typesense Cloud</a> (20+$/mo).</p>

<p>Here’s how I integrated Typesense into my Ruby on Rails app to search the <code class="language-plaintext highlighter-rouge">Posts</code> table by <code class="language-plaintext highlighter-rouge">title</code> and <code class="language-plaintext highlighter-rouge">description</code>.</p>

<h3 id="1-install-typesense-interact-with-api-search-posts">1. Install Typesense, interact with API, search Posts</h3>

<p><a href="https://typesense.org/docs/guide/install-typesense.html">Install Typesense</a> and start server on port 8108:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>typesense/tap/typesense-server@27.1
curl http://localhost:8108/health
brew services start typesense-server@27.1

<span class="c"># brew services stop typesense-server@27.1</span>
</code></pre></div></div>

<p>In your Rails app, install gem <a href="https://fnf.dev/3PHqHcb">typesense-ruby</a></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle add typesense
</code></pre></div></div>

<p>Initialize the typesense client:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/typesense.rb</span>
<span class="no">TYPESENSE_CLIENT</span> <span class="o">=</span> <span class="no">Typesense</span><span class="o">::</span><span class="no">Client</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span>
  <span class="ss">nodes: </span><span class="p">[{</span>
    <span class="ss">host: </span><span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s1">'TYPESENSE_HOST'</span><span class="p">,</span> <span class="s1">'localhost'</span><span class="p">),</span>
    <span class="ss">port: </span><span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s1">'TYPESENSE_PORT'</span><span class="p">,</span> <span class="s1">'8108'</span><span class="p">),</span>
    <span class="ss">protocol: </span><span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s1">'TYPESENSE_PROTOCOL'</span><span class="p">,</span> <span class="s1">'http'</span><span class="p">)</span>
  <span class="p">}],</span>
  <span class="ss">api_key: </span><span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s1">'TYPESENSE_API_KEY'</span><span class="p">,</span> <span class="s1">'xyz'</span><span class="p">),</span>
  <span class="ss">connection_timeout_seconds: </span><span class="mi">2</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Create a service to interact with the Typesense API.</p>

<p>In this case, we use Typesense only for <code class="language-plaintext highlighter-rouge">Posts</code> model, and attributes <code class="language-plaintext highlighter-rouge">title</code> and <code class="language-plaintext highlighter-rouge">body</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/typesense_service.rb</span>
<span class="k">class</span> <span class="nc">TypesenseService</span>
  <span class="k">class</span> <span class="o">&lt;&lt;</span> <span class="nb">self</span>
    <span class="c1"># create an empty "table" of posts in typesense with attributes "title", "body", "created_at"</span>
    <span class="c1"># TypesenseService.create_schema</span>
    <span class="k">def</span> <span class="nf">create_schema</span>
      <span class="no">TYPESENSE_CLIENT</span><span class="p">.</span><span class="nf">collections</span><span class="p">.</span><span class="nf">create</span><span class="p">({</span>
                                            <span class="ss">name: </span><span class="s1">'posts'</span><span class="p">,</span>
                                            <span class="ss">fields: </span><span class="p">[</span>
                                              <span class="p">{</span> <span class="ss">name: </span><span class="s1">'title'</span><span class="p">,</span> <span class="ss">type: </span><span class="s1">'string'</span> <span class="p">},</span>
                                              <span class="p">{</span> <span class="ss">name: </span><span class="s1">'body'</span><span class="p">,</span> <span class="ss">type: </span><span class="s1">'string'</span> <span class="p">},</span>
                                              <span class="p">{</span> <span class="ss">name: </span><span class="s1">'created_at'</span><span class="p">,</span> <span class="ss">type: </span><span class="s1">'int64'</span> <span class="p">}</span>
                                            <span class="p">],</span>
                                            <span class="ss">default_sorting_field: </span><span class="s1">'created_at'</span>
                                          <span class="p">})</span>
    <span class="k">end</span>

    <span class="c1"># get info about the current state of the posts "table"</span>
    <span class="k">def</span> <span class="nf">get_schema</span>
      <span class="no">TYPESENSE_CLIENT</span><span class="p">.</span><span class="nf">collections</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">].</span><span class="nf">retrieve</span>
    <span class="k">end</span>

    <span class="c1"># drop "posts" table from typesense</span>
    <span class="k">def</span> <span class="nf">delete_schema</span>
      <span class="no">TYPESENSE_CLIENT</span><span class="p">.</span><span class="nf">collections</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">].</span><span class="nf">delete</span>
    <span class="k">end</span>

    <span class="c1"># dump all indexed posts data as a text blob</span>
    <span class="k">def</span> <span class="nf">export_documents</span>
      <span class="no">TYPESENSE_CLIENT</span><span class="p">.</span><span class="nf">collections</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">].</span><span class="nf">documents</span><span class="p">.</span><span class="nf">export</span>
    <span class="k">end</span>

    <span class="c1"># find an indexed post by id</span>
    <span class="k">def</span> <span class="nf">retrieve_document</span><span class="p">(</span><span class="nb">id</span><span class="p">)</span>
      <span class="no">TYPESENSE_CLIENT</span><span class="p">.</span><span class="nf">collections</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">].</span><span class="nf">documents</span><span class="p">[</span><span class="nb">id</span><span class="p">.</span><span class="nf">to_s</span><span class="p">].</span><span class="nf">retrieve</span>
    <span class="k">end</span>

    <span class="c1"># see how many posts are actually indexed</span>
    <span class="k">def</span> <span class="nf">documents_count</span>
      <span class="n">search_posts</span><span class="p">(</span><span class="s1">''</span><span class="p">)[</span><span class="s1">'out_of'</span><span class="p">]</span>
    <span class="k">end</span>

    <span class="c1"># CREATE - use in Active Record callback</span>
    <span class="k">def</span> <span class="nf">index_post</span><span class="p">(</span><span class="n">post</span><span class="p">)</span>
      <span class="no">TYPESENSE_CLIENT</span><span class="p">.</span><span class="nf">collections</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">].</span><span class="nf">documents</span><span class="p">.</span><span class="nf">create</span><span class="p">({</span>
                                                               <span class="ss">id: </span><span class="n">post</span><span class="p">.</span><span class="nf">id</span><span class="p">.</span><span class="nf">to_s</span><span class="p">,</span>
                                                               <span class="ss">title: </span><span class="n">post</span><span class="p">.</span><span class="nf">title</span> <span class="o">||</span> <span class="s1">''</span><span class="p">,</span>
                                                               <span class="ss">body: </span><span class="n">post</span><span class="p">.</span><span class="nf">body</span> <span class="o">||</span> <span class="s1">''</span><span class="p">,</span>
                                                               <span class="ss">created_at: </span><span class="n">post</span><span class="p">.</span><span class="nf">created_at</span><span class="p">.</span><span class="nf">to_i</span>
                                                             <span class="p">})</span>
    <span class="k">end</span>

    <span class="c1"># UPDATE - use in Active Record callback</span>
    <span class="k">def</span> <span class="nf">update_post</span><span class="p">(</span><span class="n">post</span><span class="p">)</span>
      <span class="no">TYPESENSE_CLIENT</span><span class="p">.</span><span class="nf">collections</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">].</span><span class="nf">documents</span><span class="p">[</span><span class="n">post</span><span class="p">.</span><span class="nf">id</span><span class="p">.</span><span class="nf">to_s</span><span class="p">].</span><span class="nf">update</span><span class="p">({</span>
                                                                             <span class="ss">title: </span><span class="n">post</span><span class="p">.</span><span class="nf">title</span> <span class="o">||</span> <span class="s1">''</span><span class="p">,</span>
                                                                             <span class="ss">body: </span><span class="n">post</span><span class="p">.</span><span class="nf">body</span> <span class="o">||</span> <span class="s1">''</span><span class="p">,</span>
                                                                             <span class="ss">created_at: </span><span class="n">post</span><span class="p">.</span><span class="nf">created_at</span><span class="p">.</span><span class="nf">to_i</span>
                                                                           <span class="p">})</span>
    <span class="k">end</span>

    <span class="c1"># DESTROY - use in Active Record callback</span>
    <span class="k">def</span> <span class="nf">delete_post</span><span class="p">(</span><span class="n">post_id</span><span class="p">)</span>
      <span class="no">TYPESENSE_CLIENT</span><span class="p">.</span><span class="nf">collections</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">].</span><span class="nf">documents</span><span class="p">[</span><span class="n">post_id</span><span class="p">.</span><span class="nf">to_s</span><span class="p">].</span><span class="nf">delete</span>
    <span class="k">end</span>

    <span class="c1"># Make an API call to search posts in the typesense index</span>
    <span class="k">def</span> <span class="nf">search_posts</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">options</span> <span class="o">=</span> <span class="p">{})</span>
      <span class="n">search_parameters</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">q: </span><span class="n">query</span><span class="p">,</span>
        <span class="ss">query_by: </span><span class="s1">'title,body'</span><span class="p">,</span>
        <span class="ss">sort_by: </span><span class="s1">'created_at:desc'</span><span class="p">,</span>
        <span class="ss">per_page: </span><span class="n">options</span><span class="p">[</span><span class="ss">:per_page</span><span class="p">]</span> <span class="o">||</span> <span class="mi">10</span><span class="p">,</span>
        <span class="ss">page: </span><span class="n">options</span><span class="p">[</span><span class="ss">:page</span><span class="p">]</span> <span class="o">||</span> <span class="mi">1</span>
      <span class="p">}</span>

      <span class="no">TYPESENSE_CLIENT</span><span class="p">.</span><span class="nf">collections</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">].</span><span class="nf">documents</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="n">search_parameters</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Here’s a rake task to create a Typesense table of Posts and add all Posts from our ActiveRecord Model/Postgres to the Typesense database:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># lib/tasks/typesense.rake</span>

<span class="c1"># rails typesense:setup</span>
<span class="n">namespace</span> <span class="ss">:typesense</span> <span class="k">do</span>
  <span class="n">desc</span> <span class="s1">'Create Typesense schema and index all posts'</span>
  <span class="n">task</span> <span class="ss">setup: :environment</span> <span class="k">do</span>
    <span class="no">TypesenseService</span><span class="p">.</span><span class="nf">create_schema</span>
    <span class="no">Post</span><span class="p">.</span><span class="nf">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">post</span><span class="o">|</span>
      <span class="no">TypesenseService</span><span class="p">.</span><span class="nf">index_post</span><span class="p">(</span><span class="n">post</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Run <code class="language-plaintext highlighter-rouge">rails typesense:setup</code> in the console to trigger the task, or manually run the commands in the <code class="language-plaintext highlighter-rouge">rails console</code>.</p>

<p>Now you can search posts with Typesense:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># rails c</span>
<span class="n">result</span> <span class="o">=</span> <span class="no">TypesenseService</span><span class="p">.</span><span class="nf">search_posts</span><span class="p">(</span><span class="s2">"hotwir"</span><span class="p">)</span>
<span class="c1"># or</span>
<span class="n">result</span> <span class="o">=</span> <span class="no">TypesenseService</span><span class="p">.</span><span class="nf">search_posts</span><span class="p">(</span><span class="s2">"hotwir"</span><span class="p">,</span> <span class="ss">per_page: </span><span class="mi">5</span><span class="p">,</span> <span class="ss">page: </span><span class="mi">2</span><span class="p">)</span>
</code></pre></div></div>

<p>Next, you want to keep the Typesense posts index in sync!</p>

<p>Add the callbacks to the <code class="language-plaintext highlighter-rouge">Post</code> model:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/post.rb</span>
 <span class="n">after_create_commit</span> <span class="k">do</span>
    <span class="no">TypesenseService</span><span class="p">.</span><span class="nf">index_post</span><span class="p">(</span><span class="nb">self</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="n">after_update_commit</span> <span class="k">do</span>
    <span class="no">TypesenseService</span><span class="p">.</span><span class="nf">update_post</span><span class="p">(</span><span class="nb">self</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="n">after_destroy_commit</span> <span class="k">do</span>
    <span class="no">TypesenseService</span><span class="p">.</span><span class="nf">delete_post</span><span class="p">(</span><span class="nb">id</span><span class="p">)</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>You can also make CURL requests to the Typesense server using your API key (<code class="language-plaintext highlighter-rouge">xyz</code> is the default key).</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-H</span> <span class="s2">"X-TYPESENSE-API-KEY: xyz"</span> http://localhost:8108/collections/posts/documents/2
curl <span class="nt">-H</span> <span class="s2">"X-TYPESENSE-API-KEY: xyz"</span> http://localhost:8108/stats.json
</code></pre></div></div>

<h3 id="2-search-ui-routes-controller-views">2. Search UI. Routes, Controller, Views</h3>

<p>Create a route to search for posts at <code class="language-plaintext highlighter-rouge">localhost:3000/posts/search</code>.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resources</span> <span class="ss">:posts</span> <span class="k">do</span>
  <span class="n">collection</span> <span class="k">do</span>
    <span class="n">get</span> <span class="ss">:search</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Make a search request to your Typesense server and handle the result:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/posts_controller.rb</span>
  <span class="k">def</span> <span class="nf">search</span>
    <span class="n">results</span> <span class="o">=</span> <span class="k">if</span> <span class="n">params</span><span class="p">[</span><span class="ss">:query</span><span class="p">].</span><span class="nf">present?</span>
                <span class="no">TypesenseService</span><span class="p">.</span><span class="nf">search_posts</span><span class="p">(</span>
                  <span class="n">params</span><span class="p">[</span><span class="ss">:query</span><span class="p">],</span>
                  <span class="ss">page: </span><span class="n">params</span><span class="p">[</span><span class="ss">:page</span><span class="p">],</span>
                  <span class="ss">per_page: </span><span class="n">params</span><span class="p">[</span><span class="ss">:per_page</span><span class="p">]</span> <span class="o">||</span> <span class="mi">10</span>
                <span class="p">)</span>
              <span class="k">else</span>
                <span class="p">{</span> <span class="s1">'hits'</span> <span class="o">=&gt;</span> <span class="p">[]</span> <span class="p">}</span>
              <span class="k">end</span>

    <span class="vi">@posts</span> <span class="o">=</span> <span class="n">results</span><span class="p">[</span><span class="s1">'hits'</span><span class="p">].</span><span class="nf">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">hit</span><span class="o">|</span>
      <span class="p">{</span>
        <span class="ss">id: </span><span class="n">hit</span><span class="p">[</span><span class="s1">'document'</span><span class="p">][</span><span class="s1">'id'</span><span class="p">],</span>
        <span class="ss">title: </span><span class="n">hit</span><span class="p">[</span><span class="s1">'document'</span><span class="p">][</span><span class="s1">'title'</span><span class="p">],</span>
        <span class="ss">description: </span><span class="n">hit</span><span class="p">[</span><span class="s1">'document'</span><span class="p">][</span><span class="s1">'description'</span><span class="p">],</span>
        <span class="ss">highlight: </span><span class="n">hit</span><span class="p">[</span><span class="s1">'highlights'</span><span class="p">]</span>
      <span class="p">}</span>
    <span class="k">end</span>
    <span class="n">respond_to</span> <span class="k">do</span> <span class="o">|</span><span class="nb">format</span><span class="o">|</span>
      <span class="nb">format</span><span class="p">.</span><span class="nf">json</span> <span class="p">{</span> <span class="n">render</span> <span class="ss">json: </span><span class="vi">@posts</span> <span class="p">}</span>
      <span class="nb">format</span><span class="p">.</span><span class="nf">html</span>
    <span class="k">end</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>You can now make a CURL request to your <code class="language-plaintext highlighter-rouge">search.json</code> endpoint to get results.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl http://localhost:3000/posts/search.json?q<span class="o">=</span>hotwire
</code></pre></div></div>

<p>🚨 If the endpoint does not require authentication, be sure to add rate limiting!</p>

<p>Basic views (assuming you have Hotwire installed):</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/views/posts/search.html.erb</span>
<span class="o">&lt;</span><span class="sx">%= form_with url: search_posts_path, method: :get, data: { turbo_frame: :results} do |f| %&gt;
  &lt;%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">text_field</span> <span class="ss">:query</span><span class="p">,</span> <span class="ss">value: </span><span class="n">params</span><span class="p">[</span><span class="ss">:query</span><span class="p">],</span> <span class="ss">autofocus: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">autocomplete: </span><span class="s1">'off'</span><span class="p">,</span> <span class="ss">autocorrect: </span><span class="s1">'off'</span><span class="p">,</span> <span class="ss">oninput: </span><span class="s2">"this.form.requestSubmit()"</span> <span class="o">%&gt;</span>
  <span class="o">&lt;</span><span class="sx">%= f.submit %&gt;
&lt;% end %&gt;

&lt;br&gt;

&lt;%=</span> <span class="n">turbo_frame_tag</span> <span class="ss">:results</span><span class="p">,</span> <span class="ss">target: </span><span class="s2">"_top"</span><span class="p">,</span> <span class="ss">data: </span><span class="p">{</span> <span class="ss">turbo_action: </span><span class="s2">"advance"</span> <span class="p">}</span> <span class="k">do</span> <span class="sx">%&gt;
  &lt;% if @posts.any? %&gt;</span>
    <span class="o">&lt;</span><span class="sx">% @posts.each </span><span class="k">do</span> <span class="o">|</span><span class="n">result</span><span class="o">|</span> <span class="sx">%&gt;
      &lt;%= render "search_result", result: %&gt;</span>
    <span class="o">&lt;</span><span class="sx">% end </span><span class="o">%&gt;</span>
  <span class="o">&lt;</span><span class="sx">% elsif </span><span class="n">params</span><span class="p">[</span><span class="ss">:query</span><span class="p">].</span><span class="nf">present?</span> <span class="sx">%&gt;
    No results found for "&lt;%= params[:query] %&gt;</span><span class="s2">"
  &lt;% end %&gt;
&lt;% end %&gt;
</span></code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/views/posts/_search_result.html.erb</span>
<span class="o">&lt;</span><span class="sx">%= link_to post_path(result[:id]) do %&gt;
  &lt;h3&gt;
    &lt;% if result[:highlight]&amp;.find { |h| h["field"] =</span><span class="o">=</span> <span class="s2">"title"</span> <span class="p">}</span> <span class="o">%&gt;</span>
      <span class="o">&lt;</span><span class="sx">%= sanitize result[:highlight].find { |h| h["field"] =</span><span class="o">=</span> <span class="s2">"title"</span> <span class="p">}[</span><span class="s2">"snippet"</span><span class="p">]</span> <span class="o">%&gt;</span>
    <span class="o">&lt;</span><span class="sx">% else </span><span class="o">%&gt;</span>
      <span class="o">&lt;</span><span class="sx">%= result[:title] %&gt;
    &lt;% end %&gt;
  &lt;/h3&gt;

  &lt;% if result[:description].present? %&gt;
    &lt;p&gt;&lt;%=</span> <span class="n">result</span><span class="p">[</span><span class="ss">:description</span><span class="p">]</span> <span class="o">%&gt;&lt;</span><span class="sr">/p&gt;
  &lt;% end %&gt;

  &lt;% if result[:highlight]&amp;.find { |h| h["field"] == "body" } %&gt;
    &lt;div&gt;
      &lt;%= sanitize result[:highlight].find { |h| h["field"] == "body" }["snippet"] %&gt;
    &lt;/</span><span class="n">div</span><span class="o">&gt;</span>
  <span class="o">&lt;</span><span class="sx">% end </span><span class="o">%&gt;</span>
<span class="o">&lt;</span><span class="sx">% end </span><span class="o">%&gt;</span>
</code></pre></div></div>

<p>Voila! Now you have a Typesense search server running locally and connected to your Rails app for search.</p>

<p>ℹ️ Typesense is working on <a href="https://fnf.dev/4jBiZhw">gem typesense-rails</a> that will make interacting with the API even easier. I’m really looking forward to that. In the meantime, I think my <code class="language-plaintext highlighter-rouge">TypesenseService</code> approach is very good.</p>

<p>🙏 Thanks a lot to <a href="https://typesense.org">Typesense</a> for sponsoring this blogpost.
I wanted to get deeper into search engines, and this was the perfect opportunity.
I know what I’m using next time I need “advanced search”!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="typesense" /><category term="search" /><category term="rails" /><summary type="html"><![CDATA[If you want to search millions of records by multiple attributes, disregard typos like “JSON”/”Jason”, it makes sense to integrate separate search server, rather than overloading your Postgres database.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Starting an ecommerce store: not so easy. My story</title><link href="https://blog.superails.com/shopify-first-experience" rel="alternate" type="text/html" title="Starting an ecommerce store: not so easy. My story" /><published>2025-01-18T00:00:00+00:00</published><updated>2025-01-18T00:00:00+00:00</updated><id>https://blog.superails.com/shopify-first-experience</id><content type="html" xml:base="https://blog.superails.com/shopify-first-experience"><![CDATA[<p>So, my friend sells &amp; ships books in his country.</p>

<p>He found a very good niche, and currently ships around 1,000 books per month.</p>

<p>Marketing only via instagram &amp; tiktok.</p>

<p>The crazy thing is that <strong>all the sales go through whatsapp messaging</strong> (zero automation!)</p>

<p>More clients than he (and his team!) can handle.</p>

<p>So, we sat together and tried setting up a <strong>Shopify store</strong>….</p>

<p>Took us <strong>4 days</strong> to fill in all the minimal required data (store details, products, shipment details, policies, basic ecommerce store styling).</p>

<p>The price of the basic plan is around <strong>$60/year</strong> -&gt; that’s what we were aiming for.</p>

<h3 id="main-blockers">Main blockers:</h3>

<h4 id="1-setting-up-delivery">1. Setting up delivery.</h4>

<p>We required an add-on “select a delivery locker”.</p>

<p><img src="/assets/images/inpost.png" alt="select a delivery locker InPost" /></p>

<p>The minimal Shopify plan that allows this feature is <strong>$960/year</strong>.</p>

<p>“Select a locker” is the most preferred delivery option by clients today.</p>

<p>This is a giant cost for just one feature.</p>

<h4 id="2-connecting-a-payment-processor">2. Connecting a payment processor.</h4>

<p>The local payment processor “Przelewy24” has been verifying the vendor for 4 weeks already (they are very slow) =&gt; can not start selling.</p>

<h3 id="minor-inconveniences">Minor inconveniences</h3>

<ol>
  <li>Styling the store - too much freedom to make it ugly</li>
  <li>Adding legal policies (return policy, terms &amp; conditions)</li>
</ol>]]></content><author><name>Yaroslav Shmarov</name></author><category term="shopify" /><category term="ecommerce" /><summary type="html"><![CDATA[So, my friend sells &amp; ships books in his country.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My VS Code / Cursor plugins in 2025</title><link href="https://blog.superails.com/vscode-cursor-extensions-rails" rel="alternate" type="text/html" title="My VS Code / Cursor plugins in 2025" /><published>2024-12-25T00:00:00+00:00</published><updated>2024-12-25T00:00:00+00:00</updated><id>https://blog.superails.com/vscode-cursor-extensions-rails</id><content type="html" xml:base="https://blog.superails.com/vscode-cursor-extensions-rails"><![CDATA[<p>I always add <a href="/install-and-use-rubocop">rubocop</a> &amp; <a href="/erb-linting">erb_lint</a> to my projects.</p>

<p>These tools run ruby &amp; erb lint on terminal command, and in CI.</p>

<p>But you can run linters <strong>one step earlier</strong>: in the code editor when clicking “Save”.</p>

<p>For this you will need to install &amp; configure some IDE extensions.</p>

<p>I read <a href="https://railsnotes.xyz/blog/vscode-rails-setup">Railsnotes VS Code Rails setup</a>, but it feels incomplete.</p>

<p>Here’s the setup that works best for me:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">//</span><span class="w"> </span><span class="err">.vscode/extensions.json</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"recommendations"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"esbenp.prettier-vscode"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"bradlc.vscode-tailwindcss"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"heybourn.headwind"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"eamodio.gitlens"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"Shopify.ruby-lsp"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"elia.erb-formatter"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"github.copilot"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"github.copilot-chat"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"marcoroth.stimulus-lsp"</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>About each extension:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">esbenp.prettier-vscode</code> - format CSS, JS</li>
  <li><code class="language-plaintext highlighter-rouge">bradlc.vscode-tailwindcss</code> - TailwindCSS autocomplete &amp; style check</li>
  <li><code class="language-plaintext highlighter-rouge">heybourn.headwind</code> - TailwindCSS style ordering</li>
  <li><code class="language-plaintext highlighter-rouge">eamodio.gitlens</code> - see who did the last change to this line of code</li>
  <li><code class="language-plaintext highlighter-rouge">Shopify.ruby-lsp</code> - Ruby LSP</li>
  <li><code class="language-plaintext highlighter-rouge">elia.erb-formatter</code> - format ERB (requires <a href="https://github.com/nebulab/erb-formatter">a gem</a>). I tried all the ERB formatters (<code class="language-plaintext highlighter-rouge">manuelpuyol.erb-linter</code> &amp; <code class="language-plaintext highlighter-rouge">aliariff.vscode-erb-beautify</code>), however they format only ERB well, not HTML.</li>
  <li><code class="language-plaintext highlighter-rouge">github.copilot</code> - (No need in Cursor IDE)</li>
  <li><code class="language-plaintext highlighter-rouge">github.copilot-chat</code> - (No need in Cursor IDE)</li>
  <li><code class="language-plaintext highlighter-rouge">marcoroth.stimulus-lsp</code> - Stimulus autocomplete</li>
</ul>

<p>To ensure <strong>lint on Save</strong> works,</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">//</span><span class="w"> </span><span class="err">.vscode/settings.json</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"tailwindCSS.includeLanguages"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"html.erb"</span><span class="p">:</span><span class="w"> </span><span class="s2">"html"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"editor.defaultFormatter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"esbenp.prettier-vscode"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"editor.formatOnSave"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"[erb]"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="err">//</span><span class="w"> </span><span class="nl">"editor.defaultFormatter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"esbenp.prettier-vscode"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"editor.defaultFormatter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"elia.erb-formatter"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"editor.formatOnSave"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"prettier.semi"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
  </span><span class="nl">"prettier.trailingComma"</span><span class="p">:</span><span class="w"> </span><span class="s2">"none"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"prettier.singleQuote"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"files.associations"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"*.html.erb"</span><span class="p">:</span><span class="w"> </span><span class="s2">"erb"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"erb-formatter.lineLength"</span><span class="p">:</span><span class="w"> </span><span class="mi">180</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>To make <code class="language-plaintext highlighter-rouge">elia.erb-formatter</code> work, add the gem:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">group</span> <span class="ss">:development</span> <span class="k">do</span>
  <span class="n">gem</span> <span class="s2">"erb-formatter"</span><span class="p">,</span> <span class="s2">"~&gt; 0.7.3"</span><span class="p">,</span> <span class="ss">require: </span><span class="kp">false</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Other notable extensions”</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">ckolkman.vscode-postgres</code> - access your Database</li>
  <li><code class="language-plaintext highlighter-rouge">tomoki1207.pdf</code> - preview PDF in IDE</li>
  <li><code class="language-plaintext highlighter-rouge">redhat.vscode-yaml</code> - yaml highlighting</li>
  <li><code class="language-plaintext highlighter-rouge">karunamurti.haml</code> - HAML syntax highlighting</li>
  <li><code class="language-plaintext highlighter-rouge">davidanson.vscode-markdownlint</code> - Markdown lint</li>
</ul>

<p>That’s it!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="cursor" /><category term="ide" /><category term="vscode" /><category term="ruby" /><category term="rails" /><summary type="html"><![CDATA[I always add rubocop &amp; erb_lint to my projects.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My recommended Jekyll plugins in 2025</title><link href="https://blog.superails.com/jekyll-plugins" rel="alternate" type="text/html" title="My recommended Jekyll plugins in 2025" /><published>2024-12-24T00:00:00+00:00</published><updated>2024-12-24T00:00:00+00:00</updated><id>https://blog.superails.com/jekyll-plugins</id><content type="html" xml:base="https://blog.superails.com/jekyll-plugins"><![CDATA[<h3 id="extend-minima-theme">Extend <a href="https://github.com/jekyll/minima">Minima</a> theme</h3>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># _config.yml</span>
<span class="na">minima</span><span class="pi">:</span>
  <span class="c1"># skin: classic</span>
  <span class="na">skin</span><span class="pi">:</span> <span class="s">solarized-dark</span>
  <span class="na">social_links</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="pi">{</span> <span class="nv">platform</span><span class="pi">:</span> <span class="nv">github</span><span class="pi">,</span> <span class="nv">user_url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://github.com/jekyll/jekyll"</span> <span class="pi">}</span>
    <span class="pi">-</span> <span class="pi">{</span> <span class="nv">platform</span><span class="pi">:</span> <span class="nv">twitter</span><span class="pi">,</span> <span class="nv">user_url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://twitter.com/jekyllrb"</span> <span class="pi">}</span>
<span class="na">author</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">John Smith</span>
  <span class="na">email</span><span class="pi">:</span> <span class="s2">"</span><span class="s">john.smith@foobar.com"</span>
<span class="na">show_excerpts</span><span class="pi">:</span> <span class="kc">true</span>
</code></pre></div></div>

<h3 id="add-a-custom-page">Add a custom page</h3>

<p>Copypaste <code class="language-plaintext highlighter-rouge">about.md</code> and rename the new file to <code class="language-plaintext highlighter-rouge">newsletter.md</code>, inside too.</p>

<p>Add links to navbar:</p>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># _config.yml</span>
<span class="na">header_pages</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">about.md</span>
  <span class="pi">-</span> <span class="s">newsletter.md</span>
</code></pre></div></div>

<p>I advise you to also rename <code class="language-plaintext highlighter-rouge">.markdown</code> files to <code class="language-plaintext highlighter-rouge">.md</code>.</p>

<h3 id="open-external-links-in-new-tab">Open external links in new tab</h3>

<p>Use <a href="https://github.com/keithmifsud/jekyll-target-blank">gem Jekyll target blank</a></p>

<h3 id="gem-jekyll-seo-tag"><a href="https://github.com/jekyll/jekyll-seo-tag">gem Jekyll SEO tag</a></h3>

<p>No action required! Minima theme has SEO tags installed by default.</p>

<h3 id="gem-jekyll-opengraph-image"><a href="https://github.com/igor-alexandrov/jekyll-og-image">gem Jekyll Opengraph image</a></h3>

<p>Add image previews to social sharing.</p>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># _config.yml</span>

<span class="c1"># add these plugins</span>
<span class="na">plugins</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">jekyll-seo-tag</span>
  <span class="pi">-</span> <span class="s">jekyll-og-image</span>

<span class="c1"># customise og_image</span>
<span class="na">og_image</span><span class="pi">:</span>
  <span class="na">output_dir</span><span class="pi">:</span> <span class="s2">"</span><span class="s">assets/images/og"</span>
  <span class="na">image</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/assets/images/igor.jpeg"</span>
  <span class="na">domain</span><span class="pi">:</span> <span class="s2">"</span><span class="s">igor.works"</span>
  <span class="na">border_bottom</span><span class="pi">:</span>
    <span class="na">width</span><span class="pi">:</span> <span class="m">20</span>
    <span class="na">fill</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">#820C02"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">#A91401"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">#D51F06"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">#DE3F24"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">#EDA895"</span>
</code></pre></div></div>

<p>Add libvips to CI:</p>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .github/workflows/jekyll.yml</span>
<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="c1"># ADD THIS -&gt;</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install libvips</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">DEBIAN_FRONTEND</span><span class="pi">:</span> <span class="s">noninteractive</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">sudo apt-get install --fix-missing libvips</span>
</code></pre></div></div>

<h3 id="gem-jekyll-sitemap"><a href="https://github.com/jekyll/jekyll-sitemap">gem Jekyll Sitemap</a></h3>

<p>Important for search engines.</p>

<p>Install the gem and you can visit <a href="http://localhost:4000/sitemap.xml">http://localhost:4000/sitemap.xml</a></p>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># _config.yml</span>
<span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://example.com"</span>
<span class="na">plugins</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">jekyll-sitemap</span>
</code></pre></div></div>

<h3 id="gem-jekyll-redirecfrom"><a href="https://github.com/jekyll/jekyll-redirect-from">gem Jekyll RedirecFrom</a></h3>

<p>Redirect (old) URLs to the current post.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># _posts/2024-12-24-jekyll-plugins.md
<span class="p">---
layout: post
title: "Recommended Jekyll plugins"
categories: jekyll theming
</span><span class="gi">+redirect_from:
+  - /old-link
+  - /other
</span><span class="p">---
</span></code></pre></div></div>

<p>Now <code class="language-plaintext highlighter-rouge">http://localhost:4000/old-link</code> will redirect to <code class="language-plaintext highlighter-rouge">http://localhost:4000/jekyll-plugins</code>.</p>

<p>And <code class="language-plaintext highlighter-rouge">http://localhost:4000/other</code> will redirect to <code class="language-plaintext highlighter-rouge">http://localhost:4000/jekyll-plugins</code>.</p>

<h3 id="link-to-edit-page-on-github">Link to edit page on Github</h3>

<p>In <code class="language-plaintext highlighter-rouge">github_edit_url</code> input your repo url</p>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># _config.yml</span>
<span class="na">github_edit_url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://github.com/yshmarov/yshmarov.github.io/blob/master/"</span>
</code></pre></div></div>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- _layouts/post.html --&gt;</span>
<span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"https://github.com/yshmarov/yshmarov.github.io/blob/master/_posts/2024-12-24-jekyll-plugins.md"</span> <span class="na">target=</span><span class="s">"_blank"</span><span class="nt">&gt;</span>Edit this page<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<h3 id="discovery-feature-tag-pages">Discovery feature: Tag pages</h3>

<p>Use gem <a href="https://github.com/pattex/jekyll-tagging">Jekyll Tagging</a></p>

<p>On a post page, display links to tags.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># _posts/2024-12-24-jekyll-plugins.md
<span class="p">---
layout: post
title: "Recommended Jekyll plugins"
</span><span class="gi">+tags: ruby rails
</span><span class="p">---
</span></code></pre></div></div>

<p>Be sure to set layout to <code class="language-plaintext highlighter-rouge">base</code></p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># _layouts/tag_page.html
<span class="p">---
</span><span class="gd">-layout: default
</span><span class="gi">+layout: base
</span><span class="p">---
</span></code></pre></div></div>

<p>Display tags for each post in the post page:</p>

<p><code class="language-plaintext highlighter-rouge">html
&lt;!-- _layouts/post.html --&gt;Tags:&lt;a href="/tag/ruby"&gt;ruby&lt;/a&gt;, &lt;a href="/tag/rails"&gt;rails&lt;/a&gt;</code></p>

<p><a href="https://github.com/yshmarov/yshmarov.github.io/commit/c8b19cc0861bb451a390845f8eb7da1b8b28f1a1#diff-e73b35364c60ba845bb11a95b54e7b8e0439b5aafc61723021cd0ea7b56a709cR41">Here’s how</a> I implemented it in another app.</p>

<h3 id="discovery-feature-list-of-similar-posts">Discovery feature: List of similar posts</h3>

<p>Use <a href="https://github.com/toshimaru/jekyll-tagging-related_posts">gem jekyll-tagging-related_posts</a></p>

<p>On a post page, display list of similar posts (based on amount of matching tags).</p>

<h3 id="add-anchor-tags-to-headings">Add anchor tags to headings</h3>

<p><a href="https://github.com/yshmarov/yshmarov.github.io/pull/3">Copy this</a></p>

<h3 id="other-plugins--features-to-consider">Other plugins &amp; features to consider:</h3>

<ul>
  <li>Link to prev, next post</li>
  <li><a href="https://github.com/nhoizey/jekyll-postfiles">jekyll-postfiles</a> - scope files to folders</li>
  <li><a href="https://github.com/jekyll/jekyll-import">jekyll-import</a> - import blog from other platform</li>
  <li><a href="https://github.com/jekyll/github-metadata">github-metadata</a> - looks useful, but no idea why</li>
  <li><a href="https://github.com/jekyll/jekyll-admin">jekyll-admin</a> - does not work for me</li>
  <li><a href="https://github.com/jekyll/jekyll-gist">jekyll-gist</a> - embed gists</li>
</ul>

<p>That’s it!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="jekyll" /><category term="theming" /><category term="ruby" /><category term="rails" /><summary type="html"><![CDATA[Extend Minima theme]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Start a blog with Jekyll and Github Pages for free</title><link href="https://blog.superails.com/jekyll-blog-github-pages" rel="alternate" type="text/html" title="Start a blog with Jekyll and Github Pages for free" /><published>2024-12-21T00:00:00+00:00</published><updated>2024-12-21T00:00:00+00:00</updated><id>https://blog.superails.com/jekyll-blog-github-pages</id><content type="html" xml:base="https://blog.superails.com/jekyll-blog-github-pages"><![CDATA[<p>Are you even a developer, if you don’t use Git and Markdown for your blog?</p>

<p><a href="https://jekyllrb.com/">JekyllRb</a> is a static site generator. I use Jekyll for this blog. With Jekyll you can use Git and Markdown for creating your blog. Jekyll also has many extensions (libraries).</p>

<p><a href="https://pages.github.com/">Github pages</a> allows you host static sites for free.</p>

<p>To host Jekyll on Github pages and render the website on your own domain:</p>

<h2 id="1-create-a-github-repo">1. create a github repo.</h2>

<h2 id="2-create-a-jekyll-website-follow-the-docs-push-to-repo">2. create a jekyll website (follow the docs), push to repo.</h2>

<h2 id="3-deploy-on-your-own-domain">3. deploy on your own domain</h2>

<p>First, <a href="https://github.com/settings/pages_verified_domains/new">verify a domain with Github</a></p>

<p>Update DNS settings of your domain name:</p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Host</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>TXT</td>
      <td>_github…</td>
      <td>code…</td>
    </tr>
  </tbody>
</table>

<p>View active DNS settings via terminal:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dig CORSEGO.COM +noall +answer <span class="nt">-t</span> A
</code></pre></div></div>

<p><a href="https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/verifying-your-custom-domain-for-github-pages#verifying-a-domain-for-your-user-site">Docs</a></p>

<h4 id="31-host-on-root-apex-domain--www">3.1. Host on root (apex) domain &amp; WWW</h4>

<p>Assuming I want to host my website on the <code class="language-plaintext highlighter-rouge">corsego.com</code> domain.</p>

<p>In DNS settings, set CNAME to <code class="language-plaintext highlighter-rouge">www</code> &amp; <code class="language-plaintext highlighter-rouge">@</code>. <strong>You need both</strong>.</p>

<p>Value = your github handle + <code class="language-plaintext highlighter-rouge">.github.io.</code></p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Host</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CNAME</td>
      <td>@</td>
      <td>yshmarov.github.io.</td>
    </tr>
    <tr>
      <td>CNAME</td>
      <td>www</td>
      <td>yshmarov.github.io.</td>
    </tr>
    <tr>
      <td>TXT</td>
      <td>_github-pages-…</td>
      <td>vgwemr4fi24f23</td>
    </tr>
  </tbody>
</table>

<p>In your code repo, create a file named CNAME and add your domain name with <code class="language-plaintext highlighter-rouge">www</code>:</p>

<p>File <code class="language-plaintext highlighter-rouge">CNAME</code></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>www.corsego.com
</code></pre></div></div>

<p>Example:</p>

<p><img src="/assets/images/jekyll-pages-cname.png" alt="jekyll-pages-cname" /></p>

<p>In Github Pages settings should look more-less like this:</p>

<p><img src="/assets/images/jekyll-pages-settings.png" alt="jekyll-pages-settings" /></p>

<p>To trigger deploy, in the Page settings tab you can toggle branch &amp; click “Save”. Wait for the DNS to be verified. Click on Enforce HTTPS checkbox.</p>

<p><strong>This can take 10-15 minutes</strong></p>

<h4 id="32-deploy-on-a-subdomain">3.2. deploy on a subdomain</h4>

<p>Assuming I want to host my website on the <code class="language-plaintext highlighter-rouge">blog2</code> subdomain of <code class="language-plaintext highlighter-rouge">corsego.com</code> domain</p>

<p>File <code class="language-plaintext highlighter-rouge">CNAME</code></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>blog2.corsego.com
</code></pre></div></div>

<p>DNS settings:</p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Host</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CNAME</td>
      <td>blog2</td>
      <td>yshmarov.github.io.</td>
    </tr>
    <tr>
      <td>TXT</td>
      <td>_github-pages-…</td>
      <td>vgwemr4fi24f23</td>
    </tr>
  </tbody>
</table>

<h4 id="5-finish">5. Finish</h4>

<p>The page settings should look more-less like this:</p>

<p><img src="/assets/images/jekyll-pages-settings.png" alt="jekyll-pages-settings" /></p>

<p>Good luck with your blog!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="jekyll" /><summary type="html"><![CDATA[Are you even a developer, if you don’t use Git and Markdown for your blog?]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My first Ruby gem. hotwire_native_rails</title><link href="https://blog.superails.com/my-first-ruby-gem" rel="alternate" type="text/html" title="My first Ruby gem. hotwire_native_rails" /><published>2024-11-30T00:00:00+00:00</published><updated>2024-11-30T00:00:00+00:00</updated><id>https://blog.superails.com/my-first-ruby-gem</id><content type="html" xml:base="https://blog.superails.com/my-first-ruby-gem"><![CDATA[<p>I like contributing to my community.</p>

<p>In the Ruby community, some contribute by creating their own gems, or by contributing to existing projects.</p>

<p>I rarely contribute code, but I do raise issues when I see something’s not working right.</p>

<p>My main contribution has always been knowledge sharing: creating educational courses, blogposts, screencasts.</p>

<p>Today, 9.5 years since I installed Ruby for the first time, I created my first Ruby gem.</p>

<p><a href="https://rubygems.org/gems/hotwire_native_rails"><strong>gem hotwire_native_rails</strong></a> generates a lot of helpers and defaults, that make retrofiting an existing Rails app to a Hotwire Native Rails app much faster. It works smoothly on new Rails apps. Please check it out and tell me what you think!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><category term="rubygems" /><summary type="html"><![CDATA[I like contributing to my community.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hotwire Native Rails Helpers</title><link href="https://blog.superails.com/hotwire-native-rails-helpers" rel="alternate" type="text/html" title="Hotwire Native Rails Helpers" /><published>2024-11-28T00:00:00+00:00</published><updated>2024-11-28T00:00:00+00:00</updated><id>https://blog.superails.com/hotwire-native-rails-helpers</id><content type="html" xml:base="https://blog.superails.com/hotwire-native-rails-helpers"><![CDATA[<p>In previous blogposts of the Hotwire Native series I introduced helpers like <code class="language-plaintext highlighter-rouge">viewport_meta_tag</code>, <code class="language-plaintext highlighter-rouge">platform_identifier</code> &amp; <code class="language-plaintext highlighter-rouge">bridge_form_with</code>.</p>

<h3 id="pop-navigation-only-on-native">Pop navigation only on Native</h3>

<p>If after opening a page you don’t want to allow navigating to the last cached page, you will want to pop navigation with <code class="language-plaintext highlighter-rouge">turbo_action: 'replace'</code>, like it’s done in <a href="https://github.com/joemasilotti/daily-log/blob/main/rails/app/views/sessions/new.html.erb#L8">joemasilotti/daily-log/</a>.</p>

<p>But you will likely want to apply this navigation pattern <strong>only</strong> on native.</p>

<p>Create this helper</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/hotwire_native_helper.rb</span>
  <span class="k">def</span> <span class="nf">replace_if_native</span>
    <span class="k">return</span> <span class="s1">'replace'</span> <span class="k">if</span> <span class="n">turbo_native_app?</span>

    <span class="s1">'advance'</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>And apply it:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="sx">%= form_for session_path(resource_name), data: { turbo_action: replace_if_native } do |form| %&gt;
</span></code></pre></div></div>

<p>You will want to use <code class="language-plaintext highlighter-rouge">replace_if_native</code> on:</p>
<ul>
  <li>Authentication pages</li>
  <li>Forms that open in a native modal</li>
</ul>

<p>Learn more about <a href="https://turbo.hotwired.dev/handbook/drive#application-visits">Turbo replace</a></p>

<h3 id="do-not-open-internal-links-in-in-app-browser">Do not open internal links in in-app browser</h3>

<p>Sometimes on the web you want to open an internal url in a new tab (<code class="language-plaintext highlighter-rouge">target: "_blank"</code>).</p>

<p>But if you have target blank in a Hotwire Native app, it will open your internal link in an in-app browser 🚩🚩🚩</p>

<p>=&gt; I came up with this helper to override the link_to to remove target blank from internal links in Native apps</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/hotwire_native_helper.rb</span>
<span class="k">def</span> <span class="nf">link_to</span><span class="p">(</span><span class="nb">name</span> <span class="o">=</span> <span class="kp">nil</span><span class="p">,</span> <span class="n">options</span> <span class="o">=</span> <span class="kp">nil</span><span class="p">,</span> <span class="n">html_options</span> <span class="o">=</span> <span class="p">{},</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
    <span class="n">html_options</span><span class="p">[</span><span class="ss">:target</span><span class="p">]</span> <span class="o">=</span> <span class="s1">''</span> <span class="k">if</span> <span class="n">turbo_native_app?</span> <span class="o">&amp;&amp;</span> <span class="n">internal_url?</span><span class="p">(</span><span class="n">url_for</span><span class="p">(</span><span class="n">options</span><span class="p">))</span>
    <span class="k">super</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="n">options</span><span class="p">,</span> <span class="n">html_options</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">internal_url?</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
    <span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
    <span class="k">return</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">uri</span><span class="p">.</span><span class="nf">path</span><span class="p">.</span><span class="nf">present?</span> <span class="o">&amp;&amp;</span> <span class="n">uri</span><span class="p">.</span><span class="nf">host</span><span class="p">.</span><span class="nf">blank?</span>
    <span class="k">return</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">url</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="n">root_url</span><span class="p">)</span>

    <span class="kp">false</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>More patterns to come, as I dive deeper into Hotwire Native!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><summary type="html"><![CDATA[In previous blogposts of the Hotwire Native series I introduced helpers like viewport_meta_tag, platform_identifier &amp; bridge_form_with.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Conditional templates and Viewport for mobile browsers and native apps</title><link href="https://blog.superails.com/request-variants" rel="alternate" type="text/html" title="Conditional templates and Viewport for mobile browsers and native apps" /><published>2024-11-25T00:00:00+00:00</published><updated>2024-11-25T00:00:00+00:00</updated><id>https://blog.superails.com/request-variants</id><content type="html" xml:base="https://blog.superails.com/request-variants"><![CDATA[<h3 id="conditional-templates-request-variants">Conditional templates (Request variants)</h3>

<p>Sometimes you will want to render completely different views on <strong>Desktop browser</strong>, <strong>Mobile browser</strong>, and <strong>Native</strong>.</p>

<p><a href="https://guides.rubyonrails.org/layouts_and_rendering.html#the-variants-option">Request Variants</a> allow you to conditionally render different templates, like <code class="language-plaintext highlighter-rouge">index.html.erb</code> &amp; <code class="language-plaintext highlighter-rouge">index.html+mobile.erb</code>.</p>

<p><a href="https://github.com/fnando/browser">Gem Browser</a> helps to determine whether the browser:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle add browser
</code></pre></div></div>

<p>Set <code class="language-plaintext highlighter-rouge">+mobile</code> template rendering variant for both <strong>Mobile browser</strong> and <strong>Native</strong>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/concerns/detect_device.rb</span>
<span class="k">module</span> <span class="nn">DetectDevice</span>
  <span class="kp">extend</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Concern</span>

  <span class="n">included</span> <span class="k">do</span>
    <span class="n">before_action</span> <span class="ss">:set_variant</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="c1"># inspired by</span>
  <span class="c1"># https://github.com/gorails-screencasts/request-variants/commit/78d72b59a0a35ce4df2de8dcb0626001bfc87a5e#diff-95144019706bb2c1ee8edff448ecc0bf5d182e3dc4faf41b2a99d753b97b2999R8</span>
  <span class="k">def</span> <span class="nf">set_variant</span>
    <span class="c1">#case request.user_agent</span>
    <span class="c1">#when /iPhone/</span>
    <span class="c1">#  request.variant = :phone</span>
    <span class="c1">#when /iPad/</span>
    <span class="c1">#  request.variant = :tablet</span>
    <span class="c1">#end</span>

    <span class="n">browser</span> <span class="o">=</span> <span class="no">Browser</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="nf">user_agent</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">request</span><span class="p">.</span><span class="nf">variant</span> <span class="o">=</span> <span class="ss">:mobile</span> <span class="k">if</span> <span class="n">turbo_native_app?</span> <span class="o">||</span> <span class="n">browser</span><span class="p">.</span><span class="nf">device</span><span class="p">.</span><span class="nf">mobile?</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Activate the <code class="language-plaintext highlighter-rouge">DetectDevice</code> concern:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/application_controller.rb</span>
<span class="k">class</span> <span class="nc">ApplicationController</span> <span class="o">&lt;</span> <span class="no">ActionController</span><span class="o">::</span><span class="no">Base</span>
  <span class="kp">include</span> <span class="no">DetectDevice</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Now you can render:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">app/views/posts/index.html.erb</code> by default</li>
  <li><code class="language-plaintext highlighter-rouge">app/views/posts/index.html+mobile.erb</code> on mobile browsers / native apps</li>
</ul>

<p>To test this, emulate user agent from your browser.</p>

<p>Safari:</p>

<p><img src="/assets/images/safari-user-agent.png" alt="safari-user-agent" /></p>

<p>Chromium:</p>

<p><img src="/assets/images/chromium-user-agent.png" alt="chromium-user-agent" /></p>

<h3 id="viewport-scaling">Viewport scaling</h3>

<p>On touchscreens you will most likely want to disable zoom with fingers.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/application_helper.rb</span>
  <span class="k">def</span> <span class="nf">viewport_meta_tag</span>
    <span class="n">content</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'width=device-width,initial-scale=1'</span><span class="p">]</span>
    <span class="n">content</span> <span class="o">&lt;&lt;</span> <span class="s1">'maximum-scale=1, user-scalable=0'</span> <span class="k">if</span> <span class="n">turbo_native_app?</span> <span class="o">||</span> <span class="n">browser</span><span class="p">.</span><span class="nf">device</span><span class="p">.</span><span class="nf">mobile?</span>
    <span class="n">tag</span><span class="p">.</span><span class="nf">meta</span><span class="p">(</span><span class="ss">name: </span><span class="s1">'viewport'</span><span class="p">,</span> <span class="ss">content: </span><span class="n">content</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">','</span><span class="p">))</span>
  <span class="k">end</span>
</code></pre></div></div>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># app/views/layouts/application.html.erb
<span class="gd">-  &lt;meta name="viewport" content="width=device-width,initial-scale=1"&gt;
</span><span class="gi">+  &lt;%= viewport_meta_tag %&gt;
</span></code></pre></div></div>

<p>That’s it!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><category term="mobile" /><category term="ruby-on-rails" /><summary type="html"><![CDATA[Conditional templates (Request variants)]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Introduction to Nokogiri. Extract core website data</title><link href="https://blog.superails.com/nokogiri-website-metadata" rel="alternate" type="text/html" title="Introduction to Nokogiri. Extract core website data" /><published>2024-11-21T00:00:00+00:00</published><updated>2024-11-21T00:00:00+00:00</updated><id>https://blog.superails.com/nokogiri-website-metadata</id><content type="html" xml:base="https://blog.superails.com/nokogiri-website-metadata"><![CDATA[<p>Recently I was thinking of building a <strong>directory website</strong> that would list other websites/apps under different categories.</p>

<p>I would need to display some core data for each website, like:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">title</code></li>
  <li><code class="language-plaintext highlighter-rouge">description</code></li>
  <li>logo (<code class="language-plaintext highlighter-rouge">favicon</code>)</li>
  <li>screenshot/<code class="language-plaintext highlighter-rouge">opengraph image</code></li>
</ul>

<p><a href="https://github.com/sparklemotion/nokogiri">Gem Nokogiri</a> lets us easily parse HTML. It is one of the foundational Ruby gems, that many other gems rely on.</p>

<p>Here’s how we can parse any URL and extract code data with Nokogiri:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">require</span> <span class="s2">"nokogiri"</span>
<span class="nb">require</span> <span class="s2">"open-uri"</span>
<span class="nb">require</span> <span class="s2">"uri"</span>

<span class="k">class</span> <span class="nc">UrlCrawlerJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
  <span class="n">queue_as</span> <span class="ss">:default</span>

  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
    <span class="n">html</span> <span class="o">=</span> <span class="no">URI</span><span class="p">.</span><span class="nf">open</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>

    <span class="c1"># Parse the HTML with Nokogiri</span>
    <span class="n">doc</span> <span class="o">=</span> <span class="no">Nokogiri</span><span class="o">::</span><span class="no">HTML</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>

    <span class="c1"># Extract the page title</span>
    <span class="n">page_title</span> <span class="o">=</span> <span class="n">doc</span><span class="p">.</span><span class="nf">at</span><span class="p">(</span><span class="s2">"title"</span><span class="p">)</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">text</span>

    <span class="c1"># Extract the favicon URL</span>
    <span class="n">favicon</span> <span class="o">=</span> <span class="n">doc</span><span class="p">.</span><span class="nf">at</span><span class="p">(</span><span class="s1">'link[rel="icon"]'</span><span class="p">)</span>
    <span class="n">favicon_url</span> <span class="o">=</span> <span class="n">favicon</span> <span class="p">?</span> <span class="no">URI</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">favicon</span><span class="p">[</span><span class="s2">"href"</span><span class="p">]).</span><span class="nf">to_s</span> <span class="p">:</span> <span class="kp">nil</span>

    <span class="c1"># Extract the meta description</span>
    <span class="n">meta_description</span> <span class="o">=</span> <span class="n">doc</span><span class="p">.</span><span class="nf">at</span><span class="p">(</span><span class="s1">'meta[name="description"]'</span><span class="p">)</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">[</span><span class="p">](</span><span class="s2">"content"</span><span class="p">)</span>

    <span class="c1"># Extract the OpenGraph image</span>
    <span class="n">og_image</span> <span class="o">=</span> <span class="n">doc</span><span class="p">.</span><span class="nf">at</span><span class="p">(</span><span class="s1">'meta[property="og:image"]'</span><span class="p">)</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">[</span><span class="p">](</span><span class="s2">"content"</span><span class="p">)</span>
    <span class="n">og_image_url</span> <span class="o">=</span> <span class="n">og_image</span> <span class="p">?</span> <span class="no">URI</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">og_image</span><span class="p">).</span><span class="nf">to_s</span> <span class="p">:</span> <span class="kp">nil</span>

    <span class="p">{</span>
      <span class="n">page_title</span><span class="p">:,</span>
      <span class="n">favicon_url</span><span class="p">:,</span>
      <span class="n">meta_description</span><span class="p">:,</span>
      <span class="ss">og_image_url:
    </span><span class="p">}</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="store-retrieve-data">Store, Retrieve data</h3>

<p>Let’s assume you want to have a model that has a url and stores the extracted data.</p>

<p>I prefer to store this kind of data in <code class="language-plaintext highlighter-rouge">json</code> instead of creating new attributes each time.</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails g model website url payload:json
</code></pre></div></div>

<p>Parse an url and store the extracted data:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">url</span> <span class="o">=</span> <span class="s2">"http://superails.com/"</span>
<span class="n">website</span> <span class="o">=</span> <span class="no">Website</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="n">url</span><span class="p">:)</span>
<span class="n">payload</span> <span class="o">=</span> <span class="no">UrlCrawlerJob</span><span class="p">.</span><span class="nf">perform_now</span><span class="p">(</span><span class="n">website</span><span class="p">.</span><span class="nf">url</span><span class="p">)</span>
<span class="n">website</span><span class="p">.</span><span class="nf">update</span><span class="p">(</span><span class="n">payload</span><span class="p">:)</span>
</code></pre></div></div>

<p>Now you can get values with digging the json/hash:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">website</span><span class="p">.</span><span class="nf">payload</span><span class="p">[</span><span class="s2">"page_title"</span><span class="p">]</span>
<span class="o">=&gt;</span> <span class="s2">"SupeRails"</span>
</code></pre></div></div>

<p>You can make it easier with <code class="language-plaintext highlighter-rouge">OpenStruct</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/website.rb</span>
<span class="nb">require</span> <span class="s2">"ostruct"</span>

<span class="k">def</span> <span class="nf">payload_data</span>
  <span class="no">OpenStruct</span><span class="p">.</span><span class="nf">new</span> <span class="n">payload</span>
<span class="k">end</span>
<span class="c1"># payload_data.favicon</span>
<span class="c1"># website.payload_data.page_title</span>

<span class="c1"># search anywhere in the payload:</span>
<span class="c1"># Listing.ransack(payload_cont: "superails").result</span>
<span class="n">ransacker</span> <span class="ss">:payload</span> <span class="k">do</span>
  <span class="no">Arel</span><span class="p">.</span><span class="nf">sql</span><span class="p">(</span><span class="s2">"payload"</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>That’s it!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="nokogiri" /><summary type="html"><![CDATA[Recently I was thinking of building a directory website that would list other websites/apps under different categories.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Replace Disqus with Giscus comments</title><link href="https://blog.superails.com/giscus-replaces-disqus" rel="alternate" type="text/html" title="Replace Disqus with Giscus comments" /><published>2024-11-16T00:00:00+00:00</published><updated>2024-11-16T00:00:00+00:00</updated><id>https://blog.superails.com/giscus-replaces-disqus</id><content type="html" xml:base="https://blog.superails.com/giscus-replaces-disqus"><![CDATA[<p>I’ve been using Disqus comments on my blog for years.</p>

<p>It’s a good, easy to install tool.</p>

<p>However I’ve always felt uneasy about sharing my data with an external tool that tracks everything.</p>

<p>I’ve always wanted to move to another comment system.</p>

<p>Now, when <strong>Disqus is adding ads into my comment section</strong>, it’s the final straw for me. Time to leave.</p>

<p><img src="/assets/images/disqus-adds-ads.png" alt="disqus will have ads" /></p>

<p>I considered <a href="https://github.com/utterance/utterances">utterances</a> that is based on <strong>Github Issues</strong>. Free? Yes. Dev friendly? Yes.</p>

<p>However <a href="https://giscus.app">Gisqus</a> is based on <strong>Github Discussions</strong> - a new Github feature.</p>

<p>Github “Disgussions” <code class="language-plaintext highlighter-rouge">are superior to </code> “Issues” for conversations.</p>

<p>So in <a href="https://github.com/yshmarov/yshmarov.github.io/commit/d0fcd2bae3608b0b1a0fcc8627c8dde6f327b6e7">one tiny commit</a> I replaced Disqus with Giscus 🎉🎉</p>

<p>If Github was not an option, I would go with <a href="https://github.com/dpecos/mastodon-comments">mastodon-comments</a></p>

<p>I also saw <a href="https://bartoszgorka.com/github-discussion-comments-for-jekyll-blog">bartoszgorka’s solution</a> for adding Gisqus, but he does some changes in the <code class="language-plaintext highlighter-rouge">_config.yml</code> file for no useful reason.</p>

<p>Just follow the guide on <a href="https://giscus.app">Gisqus</a> and it will help you get all the right permissions and generate the script!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="jekyll" /><summary type="html"><![CDATA[I’ve been using Disqus comments on my blog for years.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hotwire Native Bridge Nav (UIMenu) Component</title><link href="https://blog.superails.com/hotwire-native-ui-menu-dropdown" rel="alternate" type="text/html" title="Hotwire Native Bridge Nav (UIMenu) Component" /><published>2024-11-12T00:00:00+00:00</published><updated>2024-11-12T00:00:00+00:00</updated><id>https://blog.superails.com/hotwire-native-ui-menu-dropdown</id><content type="html" xml:base="https://blog.superails.com/hotwire-native-ui-menu-dropdown"><![CDATA[<p><a href="https://developer.apple.com/documentation/uikit/uinavigationbar">UINavigationBar</a> in SwiftUI is the native navbar, that can have a “Back” <code class="language-plaintext highlighter-rouge">&lt;</code> navigation link, page title, or action buttons.</p>

<p><a href="https://developer.apple.com/documentation/uikit/uimenu">UIMenu</a> is a component that lets you open a small native dropdown:</p>

<p><img src="/assets/images/hotwire-navive-bridge-nav-dropdown.gif" alt="Hotwire Native UIMenu" /></p>

<p>You can add a UIMenu using <strong>Hotwire Native Bridge</strong>.</p>

<p>First, add the Bridge component to your Hotwire Native iOS app:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// NavComponent.swift</span>
<span class="kd">import</span> <span class="kt">HotwireNative</span>
<span class="kd">import</span> <span class="kt">UIKit</span>

<span class="kd">final</span> <span class="kd">class</span> <span class="kt">NavComponent</span><span class="p">:</span> <span class="kt">BridgeComponent</span> <span class="p">{</span>
    <span class="k">override</span> <span class="kd">class</span> <span class="k">var</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span> <span class="s">"nav"</span> <span class="p">}</span>

    <span class="k">override</span> <span class="kd">func</span> <span class="nf">onReceive</span><span class="p">(</span><span class="nv">message</span><span class="p">:</span> <span class="kt">Message</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">viewController</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="nf">addButton</span><span class="p">(</span><span class="nv">via</span><span class="p">:</span> <span class="n">message</span><span class="p">,</span> <span class="nv">to</span><span class="p">:</span> <span class="n">viewController</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="k">var</span> <span class="nv">viewController</span><span class="p">:</span> <span class="kt">UIViewController</span><span class="p">?</span> <span class="p">{</span>
        <span class="n">delegate</span><span class="o">.</span><span class="n">destination</span> <span class="k">as?</span> <span class="kt">UIViewController</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">addButton</span><span class="p">(</span><span class="n">via</span> <span class="nv">message</span><span class="p">:</span> <span class="kt">Message</span><span class="p">,</span> <span class="n">to</span> <span class="nv">viewController</span><span class="p">:</span> <span class="kt">UIViewController</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">data</span><span class="p">:</span> <span class="kt">MessageData</span> <span class="o">=</span> <span class="n">message</span><span class="o">.</span><span class="nf">data</span><span class="p">()</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

        <span class="k">let</span> <span class="nv">items</span><span class="p">:</span> <span class="p">[</span><span class="kt">UIAction</span><span class="p">]</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="n">items</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="n">item</span> <span class="k">in</span>

            <span class="kt">UIAction</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">title</span><span class="p">,</span>
                     <span class="nv">image</span><span class="p">:</span> <span class="kt">UIImage</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">image</span><span class="p">),</span>
                     <span class="nv">attributes</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">destructive</span> <span class="p">?</span> <span class="o">.</span><span class="nv">destructive</span> <span class="p">:</span> <span class="p">[],</span>
                     <span class="nv">state</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">state</span> <span class="o">==</span> <span class="s">"on"</span> <span class="p">?</span> <span class="o">.</span><span class="nv">on</span> <span class="p">:</span> <span class="o">.</span><span class="n">off</span>
            <span class="p">)</span> <span class="p">{</span> <span class="p">(</span><span class="n">_</span><span class="p">)</span> <span class="k">in</span>
                <span class="k">self</span><span class="o">.</span><span class="nf">onItemSelected</span><span class="p">(</span><span class="nv">item</span><span class="p">:</span> <span class="n">item</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="c1">// build the menu item</span>
        <span class="k">let</span> <span class="nv">image</span> <span class="o">=</span> <span class="kt">UIImage</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="n">data</span><span class="o">.</span><span class="n">image</span><span class="p">)</span>
        <span class="k">let</span> <span class="nv">menu</span> <span class="o">=</span> <span class="kt">UIMenu</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="n">data</span><span class="o">.</span><span class="n">title</span><span class="p">,</span> <span class="nv">children</span><span class="p">:</span> <span class="n">items</span><span class="p">)</span>
        <span class="k">let</span> <span class="nv">menuItem</span> <span class="o">=</span> <span class="kt">UIBarButtonItem</span><span class="p">(</span><span class="nv">image</span><span class="p">:</span> <span class="n">image</span><span class="p">,</span> <span class="nv">menu</span><span class="p">:</span> <span class="n">menu</span><span class="p">)</span>

        <span class="k">if</span> <span class="n">data</span><span class="o">.</span><span class="n">side</span> <span class="o">==</span> <span class="s">"right"</span> <span class="p">{</span>
            <span class="n">viewController</span><span class="o">.</span><span class="n">navigationItem</span><span class="o">.</span><span class="n">rightBarButtonItem</span> <span class="o">=</span> <span class="n">menuItem</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="n">viewController</span><span class="o">.</span><span class="n">navigationItem</span><span class="o">.</span><span class="n">leftBarButtonItem</span> <span class="o">=</span> <span class="n">menuItem</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">onItemSelected</span><span class="p">(</span><span class="nv">item</span><span class="p">:</span> <span class="kt">MenuItem</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">self</span><span class="o">.</span><span class="nf">reply</span><span class="p">(</span>
            <span class="nv">to</span><span class="p">:</span> <span class="s">"connect"</span><span class="p">,</span>
            <span class="nv">with</span><span class="p">:</span> <span class="kt">SelectionMessageData</span><span class="p">(</span><span class="nv">selectedIndex</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">index</span><span class="p">)</span>
        <span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">private</span> <span class="kd">extension</span> <span class="kt">NavComponent</span> <span class="p">{</span>
    <span class="kd">struct</span> <span class="kt">MessageData</span><span class="p">:</span> <span class="kt">Decodable</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">items</span><span class="p">:</span> <span class="p">[</span><span class="kt">MenuItem</span><span class="p">]</span>
        <span class="k">let</span> <span class="nv">image</span><span class="p">:</span> <span class="kt">String</span>
        <span class="k">let</span> <span class="nv">side</span><span class="p">:</span> <span class="kt">String</span>
        <span class="k">let</span> <span class="nv">title</span><span class="p">:</span> <span class="kt">String</span>
    <span class="p">}</span>
    <span class="kd">struct</span> <span class="kt">MenuItem</span><span class="p">:</span> <span class="kt">Decodable</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">title</span><span class="p">:</span> <span class="kt">String</span>
        <span class="k">let</span> <span class="nv">image</span><span class="p">:</span> <span class="kt">String</span>
        <span class="k">let</span> <span class="nv">destructive</span><span class="p">:</span> <span class="kt">Bool</span>
        <span class="k">let</span> <span class="nv">state</span><span class="p">:</span> <span class="kt">String</span>
        <span class="k">let</span> <span class="nv">index</span><span class="p">:</span> <span class="kt">Int</span>
    <span class="p">}</span>
    <span class="kd">struct</span> <span class="kt">SelectionMessageData</span><span class="p">:</span> <span class="kt">Encodable</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">selectedIndex</span><span class="p">:</span> <span class="kt">Int</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Add a Stimulus controller in your Web app:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/controllers/bridge/nav_controller.js</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">BridgeComponent</span><span class="p">,</span> <span class="nx">BridgeElement</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@hotwired/hotwire-native-bridge</span><span class="dl">"</span>

<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">BridgeComponent</span> <span class="p">{</span>
  <span class="kd">static</span> <span class="nx">component</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">nav</span><span class="dl">"</span>
  <span class="kd">static</span> <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="dl">"</span><span class="s2">item</span><span class="dl">"</span><span class="p">]</span>

  <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">super</span><span class="p">.</span><span class="nf">connect</span><span class="p">()</span>

    <span class="kd">const</span> <span class="nx">items</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">itemTargets</span><span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">item</span><span class="p">,</span> <span class="nx">index</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">itemElement</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">BridgeElement</span><span class="p">(</span><span class="nx">item</span><span class="p">)</span>

      <span class="k">return</span> <span class="p">{</span>
        <span class="na">title</span><span class="p">:</span> <span class="nx">itemElement</span><span class="p">.</span><span class="nx">title</span><span class="p">,</span>
        <span class="na">image</span><span class="p">:</span> <span class="nx">itemElement</span><span class="p">.</span><span class="nf">bridgeAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">image</span><span class="dl">"</span><span class="p">)</span> <span class="o">??</span> <span class="dl">"</span><span class="s2">none</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">destructive</span><span class="p">:</span> <span class="nx">item</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">turboMethod</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">delete</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">state</span><span class="p">:</span> <span class="nx">itemElement</span><span class="p">.</span><span class="nf">bridgeAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">state</span><span class="dl">"</span><span class="p">)</span> <span class="o">??</span> <span class="dl">"</span><span class="s2">off</span><span class="dl">"</span><span class="p">,</span>
        <span class="nx">index</span>
      <span class="p">}</span>
    <span class="p">})</span>

    <span class="kd">const</span> <span class="nx">element</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">bridgeElement</span>
    <span class="kd">const</span> <span class="nx">title</span> <span class="o">=</span> <span class="nx">element</span><span class="p">.</span><span class="nf">bridgeAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">title</span><span class="dl">"</span><span class="p">)</span> <span class="o">??</span> <span class="dl">""</span>
    <span class="kd">const</span> <span class="nx">side</span> <span class="o">=</span> <span class="nx">element</span><span class="p">.</span><span class="nf">bridgeAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">side</span><span class="dl">"</span><span class="p">)</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">left</span><span class="dl">"</span>
    <span class="kd">const</span> <span class="nx">image</span> <span class="o">=</span> <span class="nx">element</span><span class="p">.</span><span class="nf">bridgeAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">image</span><span class="dl">"</span><span class="p">)</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">none</span><span class="dl">"</span>

    <span class="k">this</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="dl">"</span><span class="s2">connect</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="nx">items</span><span class="p">,</span> <span class="nx">title</span><span class="p">,</span> <span class="nx">image</span><span class="p">,</span> <span class="nx">side</span> <span class="p">},</span> <span class="p">(</span><span class="nx">message</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">selectedIndex</span> <span class="o">=</span> <span class="nx">message</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">selectedIndex</span>
      <span class="kd">const</span> <span class="nx">selectedItem</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">BridgeElement</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">itemTargets</span><span class="p">[</span><span class="nx">selectedIndex</span><span class="p">]);</span>

      <span class="nx">selectedItem</span><span class="p">.</span><span class="nf">click</span><span class="p">()</span>
    <span class="p">})</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Finally, define links and icons that should appear in UIMenu</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="sx">%= tag.div data: { controller: 'bridge--nav', bridge_side: 'right', bridge_image: 'person.circle' } do %&gt;
  &lt;%=</span> <span class="n">link_to</span> <span class="s1">'Profile'</span><span class="p">,</span> <span class="n">edit_user_registration_path</span><span class="p">,</span> <span class="ss">data: </span><span class="p">{</span> <span class="ss">bridge__nav_target: </span><span class="s1">'item'</span><span class="p">,</span> <span class="ss">bridge_image: </span><span class="s1">'person.circle'</span> <span class="p">}</span> <span class="o">%&gt;</span>
  <span class="o">&lt;</span><span class="sx">%= button_to 'Sign Out', destroy_user_session_path, method: :delete, data: { bridge__nav_target: 'item', bridge_image: 'return', turbo_method: :delete, turbo: true } %&gt;
&lt;% end %&gt;
</span></code></pre></div></div>

<p>🚨 I tried using <code class="language-plaintext highlighter-rouge">link_to data: { turbo_method: :delete, turbo_confirm: "Sure?" }</code>, but it submitted even on clicking <strong>Cancel</strong> in the confirmation modal! This problem did not reoccur with <code class="language-plaintext highlighter-rouge">button_to</code>.</p>

<p>Notice that <code class="language-plaintext highlighter-rouge">bridge_side</code> can be <code class="language-plaintext highlighter-rouge">right</code> or <code class="language-plaintext highlighter-rouge">left</code>.</p>

<p>You can lookup icons to use in <code class="language-plaintext highlighter-rouge">SF Symbols</code> app.</p>

<p>Credit to beanman for <a href="https://discord.com/channels/1042568983724966018/1042569530834178058/1302027871430246452">coming up</a> with this solution.</p>

<p>John Pollard also <a href="https://www.slideshare.net/slideshow/johnpollard-hybrid-app-railsconf2024-pptx/268167413?utm_source=hotwireweekly&amp;utm_medium=email&amp;utm_campaign=week-20-new-turbo-native-videos-turbo-mount#43">talked about this component</a> in his RailsConf talk.</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><summary type="html"><![CDATA[UINavigationBar in SwiftUI is the native navbar, that can have a “Back” &lt; navigation link, page title, or action buttons.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Rails UI Frameworks and Component libraries</title><link href="https://blog.superails.com/rails-ui-kits" rel="alternate" type="text/html" title="Rails UI Frameworks and Component libraries" /><published>2024-11-12T00:00:00+00:00</published><updated>2024-11-12T00:00:00+00:00</updated><id>https://blog.superails.com/rails-ui-kits</id><content type="html" xml:base="https://blog.superails.com/rails-ui-kits"><![CDATA[<p>Building backends with Rails is amazingly fast.</p>

<p>Building responsive frontends is also easy with Hotwire.</p>

<p>But <strong>styling</strong> elements can take up <strong>a lot</strong> of time.</p>

<p>TailwindCSS &amp; ViewComponent make creating frontend components easier… but you still need to do the work creating them 💩</p>

<p>Ultimately you want to have resuable components across different apps.</p>

<p>You will want a Rails UI component library.</p>

<h2 id="rails-component-libraries">Rails Component libraries</h2>

<p>In no particular order</p>

<h3 id="phlexyuicom"><a href="https://phlexyui.com">phlexyui.com</a></h3>

<p>By <a href="https://x.com/itsdavidramos">itsdavidramos</a></p>

<h3 id="nitrokitdev"><a href="https://nitrokit.dev/">nitrokit.dev</a></h3>

<p>By <a href="https://x.com/mikker">mikker</a></p>

<h3 id="rubyuicom"><a href="https://rubyui.com">rubyui.com</a></h3>

<p>By <a href="https://x.com/SethHorsley">SethHorsley</a> &amp; <a href="https://x.com/cirdesh">cirdesh</a></p>

<h3 id="uidarkseadev"><a href="https://ui.darksea.dev">ui.darksea.dev</a></h3>

<p>By <a href="https://x.com/darkseadev">darkseadev</a></p>

<h3 id="rapidrailscc"><a href="http://rapidrails.cc">rapidrails.cc</a></h3>

<p>By <a href="http://twitter.com/ahmednadar">ahmednadar</a></p>

<h3 id="css-zero"><a href="http://github.com/lazaronixon/css-zero">css-zero</a></h3>

<p>By <a href="http://twitter.com/lazaronixon">lazaronixon</a></p>

<h3 id="railsui"><a href="https://railsui.com">RailsUI</a></h3>

<p>By <a href="https://x.com/justalever">justalever</a></p>

<h3 id="shadcnrails-componentscom"><a href="https://shadcn.rails-components.com/">shadcn.rails-components.com</a></h3>

<p>By <a href="https://x.com/aviflombaum">Avi Flombaum</a></p>

<h3 id="polaris_view_components"><a href="https://github.com/baoagency/polaris_view_components">polaris_view_components</a></h3>

<p>By <a href="https://x.com/kirplatonov">kirplatonov</a> &amp; co</p>

<p>Polaris is based on the Shopify design system.</p>

<h3 id="zestuicom"><a href="https://zestui.com">zestui.com</a></h3>

<p>By ???????? 🥸</p>

<h3 id="railsdesignercom"><a href="https://railsdesigner.com">railsdesigner.com</a></h3>

<p>By Eelco</p>

<h3 id="railscomponentsco"><a href="https://www.railscomponents.co">railscomponents.co</a></h3>

<p>By <a href="https://x.com/sm_startups">sm_startups</a></p>

<h3 id="railsbootui"><a href="https://railsbootui.com">RailsbootUI</a></h3>
<p>Rails View Components for Bootstrap</p>

<p>By <a href="https://www.dotruby.com">DotRuby</a></p>

<h3 id="rails-blocks"><a href="https://railsblocks.com">Rails Blocks</a></h3>

<p>By <a href="https://dev.to/sandu">Alexandru Golovatenco</a></p>

<p><img src="/assets/images/rails-ui-wars.png" alt="yoda ui wars" /></p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="frontend" /><summary type="html"><![CDATA[Building backends with Rails is amazingly fast.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hotwire Native Bridge Component - Prompt to leave an AppStore review</title><link href="https://blog.superails.com/hotwire-native-leave-a-review-bridge-component" rel="alternate" type="text/html" title="Hotwire Native Bridge Component - Prompt to leave an AppStore review" /><published>2024-11-11T00:00:00+00:00</published><updated>2024-11-11T00:00:00+00:00</updated><id>https://blog.superails.com/hotwire-native-leave-a-review-bridge-component</id><content type="html" xml:base="https://blog.superails.com/hotwire-native-leave-a-review-bridge-component"><![CDATA[<p>Joe Masilotti recently <a href="https://x.com/joemasilotti/status/1855980993674653995/photo/2">shared</a> his solution for invoking a prompt to leave an App Store review in a Hotwire Native app.</p>

<p><img src="/assets/images/hotwire-native-leave-a-review-prompt.png" alt="Prompt to leave an AppStore review " /></p>

<p>Implementation guide:</p>

<p>Create a <code class="language-plaintext highlighter-rouge">ReviewPromptComponent</code> in your Hotwire Native iOS app:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ReviewPromptComponent.swift</span>
<span class="kd">import</span> <span class="kt">HotwireNative</span>
<span class="kd">import</span> <span class="kt">StoreKit</span>

<span class="kd">class</span> <span class="kt">ReviewPromptComponent</span><span class="p">:</span> <span class="kt">BridgeComponent</span> <span class="p">{</span>
  <span class="k">override</span> <span class="kd">class</span> <span class="k">var</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span> <span class="s">"review-prompt"</span> <span class="p">}</span>

  <span class="kd">private</span> <span class="k">var</span> <span class="nv">viewController</span><span class="p">:</span> <span class="kt">UIViewController</span><span class="p">?</span> <span class="p">{</span>
    <span class="n">delegate</span><span class="o">.</span><span class="n">destination</span> <span class="k">as?</span> <span class="kt">UIViewController</span>
  <span class="p">}</span>

  <span class="k">override</span> <span class="kd">func</span> <span class="nf">onReceive</span><span class="p">(</span><span class="nv">message</span><span class="p">:</span> <span class="kt">HotwireNative</span><span class="o">.</span><span class="kt">Message</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="k">let</span> <span class="nv">scene</span> <span class="o">=</span> <span class="n">viewController</span><span class="p">?</span><span class="o">.</span><span class="n">view</span><span class="o">.</span><span class="n">window</span><span class="p">?</span><span class="o">.</span><span class="n">windowScene</span> <span class="p">{</span>
      <span class="kt">SKStoreReviewController</span><span class="o">.</span><span class="nf">requestReview</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="n">scene</span><span class="p">)</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Stimulus controller to invoke the StoreReview prompt:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// review_prompt_controller.js</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">BridgeComponent</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@hotwired/hotwire-native-bridge</span><span class="dl">"</span>

<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">BridgeComponent</span> <span class="p">{</span>
  <span class="kd">static</span> <span class="nx">component</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">review-prompt</span><span class="dl">"</span>

  <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">super</span><span class="p">.</span><span class="nf">connect</span><span class="p">()</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="dl">"</span><span class="s2">connect</span><span class="dl">"</span><span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Finally, initialize this stimulus controller anywhere in your HTML:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;meta</span> <span class="na">data-controller=</span><span class="s">"bridge--review-prompt"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<blockquote>
  <p>⚠ You can ask a user to leave a review 3 times in 365 days.
Do not display this component too often.
Display it after a user has a “positive experience”.</p>
</blockquote>

<p>That’s it!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><summary type="html"><![CDATA[Joe Masilotti recently shared his solution for invoking a prompt to leave an App Store review in a Hotwire Native app.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hotwire Native Bridge Menu (Action Sheet) Component</title><link href="https://blog.superails.com/hotwire-native-bridge-menu-component" rel="alternate" type="text/html" title="Hotwire Native Bridge Menu (Action Sheet) Component" /><published>2024-11-09T00:00:00+00:00</published><updated>2024-11-09T00:00:00+00:00</updated><id>https://blog.superails.com/hotwire-native-bridge-menu-component</id><content type="html" xml:base="https://blog.superails.com/hotwire-native-bridge-menu-component"><![CDATA[<p>Hotwire Native ships with a default <code class="language-plaintext highlighter-rouge">Menu</code> and <code class="language-plaintext highlighter-rouge">OverflowMenu</code> component.</p>

<p><img src="/assets/images/ios-action-sheet.png" alt="ios action sheet" /></p>

<p><code class="language-plaintext highlighter-rouge">Menu</code> let’s you invoke a native dropdown with links and buttons that you define in HTML.</p>

<p>In SwiftUI it is called <a href="https://developer.apple.com/design/human-interface-guidelines/action-sheets">Action sheets</a></p>

<p><img src="/assets/images/hotwire-native-menu-example.gif" alt="hotwire-native-menu-example" /></p>

<p><code class="language-plaintext highlighter-rouge">OverflowMenu</code> invokes the same menu, but as a native button on the top-right corner of your screen.</p>

<p><img src="/assets/images/hotwire-native-overflow-menu-example.gif" alt="hotwire-native-overflow-menu-example" /></p>

<p>To use this component, be sure to first install <code class="language-plaintext highlighter-rouge">hotwire-native-bridge</code> in your app:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./bin/importmap pin @hotwired/stimulus @hotwired/hotwire-native-bridge
</code></pre></div></div>

<p>Import the StimulusJS controllers into your app:</p>

<ul>
  <li><a href="https://github.com/hotwired/hotwire-native-demo/blob/main/public/javascript/controllers/bridge/menu_controller.js">menu_controller.js</a></li>
  <li><a href="https://github.com/hotwired/hotwire-native-demo/blob/main/public/javascript/controllers/bridge/overflow_menu_controller.js">overflow_menu_controller.js</a></li>
</ul>

<p>Ensure your ios app has these Bridge components:</p>

<ul>
  <li><a href="https://github.com/hotwired/hotwire-native-ios/blob/main/Demo/Bridge/MenuComponent.swift">MenuComponent.swift</a></li>
  <li><a href="https://github.com/hotwired/hotwire-native-ios/blob/main/Demo/Bridge/OverflowMenuComponent.swift">OverflowMenuComponent.swift</a></li>
</ul>

<p>The default HMTL example of using these menus:</p>

<ul>
  <li><a href="https://github.com/hotwired/hotwire-native-demo/blob/main/views/bridge-menu.ejs">HTML example - Menu</a></li>
  <li><a href="https://github.com/hotwired/hotwire-native-demo/blob/main/views/bridge-overflow.ejs">HTML example - OverflowMenu</a></li>
</ul>

<p>Stripped dowwn HTML example for <code class="language-plaintext highlighter-rouge">Menu</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">data-controller=</span><span class="s">"bridge--menu"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;button</span> 
    <span class="na">data-action=</span><span class="s">"click-&gt;bridge--menu#show click-&gt;menu#show"</span><span class="nt">&gt;</span>
    Open Menu
  <span class="nt">&lt;/button&gt;</span>
  <span class="nt">&lt;div&gt;</span>
    <span class="nt">&lt;p</span> <span class="na">data-bridge--menu-target=</span><span class="s">"title"</span><span class="nt">&gt;</span>Select an option<span class="nt">&lt;/p&gt;</span>
    <span class="nt">&lt;</span><span class="err">%=</span> <span class="na">link_to</span> <span class="err">"</span><span class="na">Edit</span><span class="err">",</span> <span class="na">edit_organization_path</span><span class="err">(@</span><span class="na">organization</span><span class="err">),</span> <span class="na">data:</span> <span class="err">{"</span><span class="na">bridge--menu-target</span><span class="err">"</span><span class="na">:</span> <span class="err">"</span><span class="na">item</span><span class="err">"}</span> <span class="err">%</span><span class="nt">&gt;</span>
    <span class="nt">&lt;</span><span class="err">%=</span> <span class="na">button_to</span> <span class="err">"</span><span class="na">Destroy</span><span class="err">",</span> <span class="err">@</span><span class="na">organization</span><span class="err">,</span> <span class="na">method:</span> <span class="na">:delete</span><span class="err">,</span> <span class="na">data:</span> <span class="err">{</span> <span class="err">"</span><span class="na">bridge--menu-target</span><span class="err">"</span><span class="na">:</span> <span class="err">"</span><span class="na">item</span><span class="err">",</span> <span class="na">turbo_confirm:</span> <span class="err">"</span><span class="na">Are</span> <span class="na">you</span> <span class="na">sure</span><span class="err">?"</span> <span class="err">}</span> <span class="err">%</span><span class="nt">&gt;</span>
    <span class="nt">&lt;button</span> <span class="na">data-bridge--menu-target=</span><span class="s">"item"</span><span class="nt">&gt;</span>
      Option Three
    <span class="nt">&lt;/button&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>For <code class="language-plaintext highlighter-rouge">OverflowMenu</code>, just initialize the <code class="language-plaintext highlighter-rouge">bridge--overflow-menu</code> controller on the action button:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;div data-controller="bridge--menu"&gt;
  &lt;button 
<span class="gi">+    data-controller="bridge--overflow-menu"
</span>    data-action="click-&gt;bridge--menu#show click-&gt;menu#show"&gt;
    Open Menu
  &lt;/button&gt;
  &lt;div&gt;
    &lt;p data-bridge--menu-target="title"&gt;Select an option&lt;/p&gt;
    &lt;%= link_to "Edit", edit_organization_path(@organization), data: {"bridge--menu-target": "item"} %&gt;
    &lt;%= button_to "Destroy", @organization, method: :delete, data: { "bridge--menu-target": "item", turbo_confirm: "Are you sure?" } %&gt;
    &lt;button data-bridge--menu-target="item"&gt;
      Option Three
    &lt;/button&gt;
  &lt;/div&gt;
&lt;/div&gt;
</code></pre></div></div>

<p>As a next step, you might want to completely hide the menu element <code class="language-plaintext highlighter-rouge">style="display: hidden"</code>, and only display the native elements generated by it.</p>

<p>That’s it!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><summary type="html"><![CDATA[Hotwire Native ships with a default Menu and OverflowMenu component.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Custom chapters with Vimeo Player API</title><link href="https://blog.superails.com/vimeo-player-js-timeline-navigation" rel="alternate" type="text/html" title="Custom chapters with Vimeo Player API" /><published>2024-11-08T00:00:00+00:00</published><updated>2024-11-08T00:00:00+00:00</updated><id>https://blog.superails.com/vimeo-player-js-timeline-navigation</id><content type="html" xml:base="https://blog.superails.com/vimeo-player-js-timeline-navigation"><![CDATA[<p>I’ve just implemented Video timestamp navigation on videos embeded on <a href="https://superails.com/posts">SupeRails</a>:</p>

<p><img src="/assets/images/superails-video-chapters.gif" alt="superails chapter navigation inside video" /></p>

<p>I was inspired by Chapters on Youtube:</p>

<p><img src="/assets/images/youtube-chapters.png" alt="youtube video chapters" /></p>

<p>In my app I embed videos with Vimeo.</p>

<p>To navigate to a timestamp in the vimeo player, you need to use the <a href="https://github.com/vimeo/player.js">https://github.com/vimeo/player.js</a> API.</p>

<p>First, install the package <a href="https://github.com/vimeo/player.js">https://github.com/vimeo/player.js</a></p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./bin/importmap pin @vimeo/player
rails g stimulus vimeo
</code></pre></div></div>

<p>Stimulus controller to click <code class="language-plaintext highlighter-rouge">play</code>, or <strong>navigate</strong> to a timestamp:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/controllers/vimeo_controller.js</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Controller</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@hotwired/stimulus</span><span class="dl">"</span>
<span class="k">import</span> <span class="nx">Player</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@vimeo/player</span><span class="dl">'</span><span class="p">;</span>

<span class="c1">// Connects to data-controller="vimeo"</span>
<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">Controller</span> <span class="p">{</span>
  <span class="kd">static</span> <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="dl">"</span><span class="s2">player</span><span class="dl">"</span><span class="p">]</span>

  <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="c1">// continue only if the embedded video is from Vimeo</span>
    <span class="k">try</span> <span class="p">{</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">player</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Player</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">playerTarget</span><span class="p">);</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">{}</span>
  <span class="p">}</span>

  <span class="nf">play</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">()</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">player</span><span class="p">.</span><span class="nf">play</span><span class="p">()</span>
  <span class="p">}</span>

  <span class="nf">setCurrentTime</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">()</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">player</span><span class="p">.</span><span class="nf">setCurrentTime</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">time</span><span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Embed a vimeo player, and add play and chapter navigation buttons:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">data-controller=</span><span class="s">"vimeo"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;iframe</span> <span class="na">src=</span><span class="s">"https://player.vimeo.com/video/1023706220?h=7e9bb172b6"</span>
          <span class="na">frameborder=</span><span class="s">"0"</span>
          <span class="na">data-vimeo-target=</span><span class="s">"player"</span>
          <span class="na">allowfullscreen</span><span class="nt">&gt;</span>

  <span class="nt">&lt;button</span> <span class="na">data-action=</span><span class="s">"click-&gt;vimeo#play"</span><span class="nt">&gt;</span>play<span class="nt">&lt;/button&gt;</span>
  <span class="nt">&lt;button</span> <span class="na">data-action=</span><span class="s">"vimeo#setCurrentTime"</span> <span class="na">data-time=</span><span class="s">"40"</span><span class="nt">&gt;</span>40<span class="nt">&lt;/button&gt;</span>
  <span class="nt">&lt;button</span> <span class="na">data-action=</span><span class="s">"vimeo#setCurrentTime"</span> <span class="na">data-time=</span><span class="s">"90"</span><span class="nt">&gt;</span>90<span class="nt">&lt;/button&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h3 id="parse-timestamps-from-text">Parse timestamps from text</h3>

<p>To add timestamps/chapters on Youtube, you simply edit a video description and type in the timestamps.</p>

<p>Next, Youtube parses your video description and extracts the timestamp <code class="language-plaintext highlighter-rouge">time</code> and <code class="language-plaintext highlighter-rouge">description</code> values.</p>

<p>We can parse a video description with Regex, and turn timestamps into buttons:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/application_helper.rb</span>
  <span class="c1"># find all timestamps (0:00, 5:31, etc) in a text and convert them to buttons</span>
  <span class="k">def</span> <span class="nf">timestamps_to_buttons</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
    <span class="n">text</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="sr">/(\d+:\d+)/</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">match</span><span class="o">|</span>
      <span class="n">time</span> <span class="o">=</span> <span class="n">match</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="s1">':'</span><span class="p">).</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:to_i</span><span class="p">)</span>
      <span class="n">seconds</span> <span class="o">=</span> <span class="n">time</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">+</span> <span class="n">time</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
      <span class="s2">"&lt;button data-action=</span><span class="se">\"</span><span class="s2">vimeo#setCurrentTime</span><span class="se">\"</span><span class="s2"> data-time=</span><span class="se">\"</span><span class="si">#{</span><span class="n">seconds</span><span class="si">}</span><span class="se">\"</span><span class="s2"> style=</span><span class="se">\"</span><span class="s2">color: blue;</span><span class="se">\"</span><span class="s2">&gt;</span><span class="si">#{</span><span class="n">match</span><span class="si">}</span><span class="s2">&lt;/button&gt;"</span>
    <span class="k">end</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>You can even turn the timestamps &amp; titles into JSON:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/application_helper.rb</span>
  <span class="k">def</span> <span class="nf">timestamps_array_from_text</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
    <span class="n">text</span><span class="p">.</span><span class="nf">scan</span><span class="p">(</span><span class="sr">/(\d+:\d+)\s(.+)/</span><span class="p">).</span><span class="nf">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">match</span><span class="o">|</span>
      <span class="n">time</span> <span class="o">=</span> <span class="n">match</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nf">split</span><span class="p">(</span><span class="s1">':'</span><span class="p">).</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:to_i</span><span class="p">)</span>
      <span class="n">seconds</span> <span class="o">=</span> <span class="p">(</span><span class="n">time</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">*</span> <span class="mi">60</span><span class="p">)</span> <span class="o">+</span> <span class="n">time</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
      <span class="p">{</span> <span class="ss">human_time: </span><span class="n">match</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="ss">time: </span><span class="n">seconds</span><span class="p">,</span> <span class="ss">text: </span><span class="n">match</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="p">}</span>
    <span class="k">end</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>Test the parser:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">test</span> <span class="s1">'timestamps_array_from_text'</span> <span class="k">do</span>
    <span class="n">text</span> <span class="o">=</span> <span class="s2">"hello world 0:00 Introduction</span><span class="se">\n</span><span class="s2">0:10 First section</span><span class="se">\n</span><span class="s2">2:30 Second section"</span>
    <span class="n">expected</span> <span class="o">=</span> <span class="p">[</span>
      <span class="p">{</span> <span class="ss">human_time: </span><span class="s1">'0:00'</span><span class="p">,</span> <span class="ss">time: </span><span class="mi">0</span><span class="p">,</span> <span class="ss">text: </span><span class="s1">'Introduction'</span> <span class="p">},</span>
      <span class="p">{</span> <span class="ss">human_time: </span><span class="s1">'0:10'</span><span class="p">,</span> <span class="ss">time: </span><span class="mi">10</span><span class="p">,</span> <span class="ss">text: </span><span class="s1">'First section'</span> <span class="p">},</span>
      <span class="p">{</span> <span class="ss">human_time: </span><span class="s1">'2:30'</span><span class="p">,</span> <span class="ss">time: </span><span class="mi">150</span><span class="p">,</span> <span class="ss">text: </span><span class="s1">'Second section'</span> <span class="p">}</span>
    <span class="p">]</span>

    <span class="n">assert_equal</span> <span class="n">expected</span><span class="p">,</span> <span class="n">timestamps_array_from_text</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>Finally, display styled, clickable timestamps anywhere on your page (but within <code class="language-plaintext highlighter-rouge">data-controller="vimeo"</code>):</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;ul&gt;</span>
  <span class="nt">&lt;</span><span class="err">%</span> <span class="na">timestamps_array_from_text</span><span class="err">(</span><span class="na">text</span><span class="err">).</span><span class="na">each</span> <span class="na">do</span> <span class="err">|</span><span class="na">timestamp</span><span class="err">|</span> <span class="err">%</span><span class="nt">&gt;</span>
    <span class="nt">&lt;li&gt;</span>
      <span class="nt">&lt;button</span> <span class="na">title=</span><span class="s">"&lt;%= timestamp[:text] %&gt;"</span> <span class="na">style=</span><span class="s">"color: blue"</span> <span class="na">data-action=</span><span class="s">"vimeo#setCurrentTime"</span> <span class="na">data-time=</span><span class="s">"&lt;%= timestamp[:time] %&gt;"</span><span class="nt">&gt;&lt;</span><span class="err">%=</span> <span class="na">timestamp</span><span class="err">[</span><span class="na">:human_time</span><span class="err">]</span> <span class="err">%</span><span class="nt">&gt;&lt;/button&gt;</span>
      <span class="nt">&lt;</span><span class="err">%=</span> <span class="na">timestamp</span><span class="err">[</span><span class="na">:text</span><span class="err">]</span> <span class="err">%</span><span class="nt">&gt;</span>
    <span class="nt">&lt;/li&gt;</span>
  <span class="nt">&lt;</span><span class="err">%</span> <span class="na">end</span> <span class="err">%</span><span class="nt">&gt;</span>
<span class="nt">&lt;/ul&gt;</span>
</code></pre></div></div>

<p>That’s it! Custom chapter navigation works!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="ruby" /><category term="rails" /><category term="youtube" /><category term="api" /><category term="vimeo" /><category term="stimulus" /><summary type="html"><![CDATA[I’ve just implemented Video timestamp navigation on videos embeded on SupeRails:]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">StimulusJS Cmd Enter to submit form</title><link href="https://blog.superails.com/stimulus-cmd-enter-to-submit" rel="alternate" type="text/html" title="StimulusJS Cmd Enter to submit form" /><published>2024-11-07T00:00:00+00:00</published><updated>2024-11-07T00:00:00+00:00</updated><id>https://blog.superails.com/stimulus-cmd-enter-to-submit</id><content type="html" xml:base="https://blog.superails.com/stimulus-cmd-enter-to-submit"><![CDATA[<p>Kasper gave Jeremy the idea of <code class="language-plaintext highlighter-rouge">Cmd+Enter</code> for submitting comments in his app:</p>

<p><img src="/assets/images/liminal-cmd-enter-submit-idea.jpeg" alt="liminal forum - submit form with keyboard" /></p>

<p>I took this idea and implemented it on <a href="https://superails.com/posts">SupeRails</a>.</p>

<p>Now, when creating a <code class="language-plaintext highlighter-rouge">Comment</code>, you can submit the form with your keyboard by clicking <code class="language-plaintext highlighter-rouge">Cmd/Ctrl+Enter</code>.</p>

<p>This is a common web behaviour nowadays.</p>

<p>Here’s how you can add <code class="language-plaintext highlighter-rouge">Cmd/Ctrl+Enter</code> to your app with StimulusJS:</p>

<h3 id="1-worst-approach">1. Worst approach</h3>

<ul>
  <li>Without using stimulus data-action keyboard events</li>
  <li>With event listeners</li>
</ul>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/controllers/form_controller.js</span>
<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">Controller</span> <span class="p">{</span>
  <span class="kd">static</span> <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="dl">"</span><span class="s2">input</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">submit</span><span class="dl">"</span><span class="p">];</span>

  <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">inputTarget</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">keydown</span><span class="dl">"</span><span class="p">,</span> <span class="k">this</span><span class="p">.</span><span class="nx">submitOnCmdEnter</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">));</span>
  <span class="p">}</span>

  <span class="nf">disconnect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">inputTarget</span><span class="p">.</span><span class="nf">removeEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">keydown</span><span class="dl">"</span><span class="p">,</span> <span class="k">this</span><span class="p">.</span><span class="nx">submitOnCmdEnter</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">));</span>
  <span class="p">}</span>

  <span class="nf">submitOnCmdEnter</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="nx">pressedCtrl</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">metaKey</span> <span class="o">||</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ctrlKey</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">pressedCtrl</span> <span class="o">&amp;&amp;</span> <span class="nx">event</span><span class="p">.</span><span class="nx">key</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">Enter</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">();</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">submitTarget</span><span class="p">.</span><span class="nf">click</span><span class="p">();</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"/posts"</span>
      <span class="na">data-controller=</span><span class="s">"form"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;textarea</span> <span class="na">data-form-target=</span><span class="s">"input"</span> <span class="na">name=</span><span class="s">"content"</span><span class="nt">&gt;&lt;/textarea&gt;</span>
  <span class="nt">&lt;button</span> <span class="na">data-form-target=</span><span class="s">"submit"</span><span class="nt">&gt;</span>Submit<span class="nt">&lt;/button&gt;</span>
<span class="nt">&lt;/form&gt;</span>
</code></pre></div></div>

<h3 id="2-slightly-better-approach">2. Slightly better approach</h3>

<p>Use data-action instead of event listener</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/controllers/form_controller.js</span>
<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">Controller</span> <span class="p">{</span>
  <span class="nf">submitOnCmdEnter</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="nx">pressedCtrl</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">metaKey</span> <span class="o">||</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ctrlKey</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">pressedCtrl</span> <span class="o">&amp;&amp;</span> <span class="nx">event</span><span class="p">.</span><span class="nx">key</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">Enter</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">();</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">submitTarget</span><span class="p">.</span><span class="nf">click</span><span class="p">();</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"/posts"</span>
      <span class="na">data-controller=</span><span class="s">"form"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;textarea</span> <span class="na">data-action=</span><span class="s">"keydown-&gt;form#submitOnCmdEnter"</span> <span class="na">name=</span><span class="s">"content"</span><span class="nt">&gt;&lt;/textarea&gt;</span>
<span class="nt">&lt;/form&gt;</span>
</code></pre></div></div>

<h3 id="3-best-approach">3. Best approach</h3>

<p>You don’t even need to define <code class="language-plaintext highlighter-rouge">Cmd/Ctrl+Enter</code> in your Stimulus controller!</p>

<p>Just use StimulusJS <a href="https://stimulus.hotwired.dev/reference/actions#keyboardevent-filter">Keyboard events</a></p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/controllers/form_controller.js</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Controller</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@hotwired/stimulus</span><span class="dl">"</span>

<span class="c1">// Connects to data-controller="form"</span>
<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">Controller</span> <span class="p">{</span>
  <span class="nf">submit</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">element</span><span class="p">.</span><span class="nf">requestSubmit</span><span class="p">();</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"/posts"</span>
      <span class="na">data-controller=</span><span class="s">"form"</span>
      <span class="na">data-action=</span><span class="s">"keydown.ctrl+enter-&gt;form#submit keydown.meta+enter-&gt;form#submit"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;textarea</span> <span class="na">name=</span><span class="s">"content"</span><span class="nt">&gt;&lt;/textarea&gt;</span>
<span class="nt">&lt;/form&gt;</span>
</code></pre></div></div>

<p>That’s it! Now visit SupeRails, and try creating a comment with <code class="language-plaintext highlighter-rouge">Cmd/Ctrl+Enter</code>!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="keyboard" /><category term="hotkeys" /><category term="stimulusjs" /><category term="stimulus" /><summary type="html"><![CDATA[Kasper gave Jeremy the idea of Cmd+Enter for submitting comments in his app:]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Render Markdown FILES in Rails app</title><link href="https://blog.superails.com/render-markdown-pages-in-rails" rel="alternate" type="text/html" title="Render Markdown FILES in Rails app" /><published>2024-11-01T00:00:00+00:00</published><updated>2024-11-01T00:00:00+00:00</updated><id>https://blog.superails.com/render-markdown-pages-in-rails</id><content type="html" xml:base="https://blog.superails.com/render-markdown-pages-in-rails"><![CDATA[<p>I love this new era of ultra-simplified websites.</p>

<p>PlanetScale uses MARKDOWN for its’ marketing pages.</p>

<p><img src="/assets/images/prefer-markdown.png" alt="planetscale uses markdown for marketing pages" /></p>

<p>Focus on delivering the value, not the flashy animations! 🎯</p>

<p>And you can be MUCH more productive with Markdown than with HTML!</p>

<p>Here’s how you can render markdown <strong>files</strong> in your Rails app:</p>

<p>Add <a href="https://github.com/vmg/redcarpet">gem redcarpet</a></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># console</span>
<span class="n">bundle</span> <span class="n">add</span> <span class="n">redcarpet</span>
</code></pre></div></div>

<p>Create a helper to parse markdown to HTML:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/application_helper.rb</span>
  <span class="k">def</span> <span class="nf">markdown_to_html</span><span class="p">(</span><span class="n">markdown_text</span><span class="p">)</span>
    <span class="n">renderer</span> <span class="o">=</span> <span class="no">Redcarpet</span><span class="o">::</span><span class="no">Render</span><span class="o">::</span><span class="no">HTML</span><span class="p">.</span><span class="nf">new</span>
    <span class="n">markdown</span> <span class="o">=</span> <span class="no">Redcarpet</span><span class="o">::</span><span class="no">Markdown</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">renderer</span><span class="p">,</span> <span class="n">extensions</span> <span class="o">=</span> <span class="p">{})</span>
    <span class="n">markdown</span><span class="p">.</span><span class="nf">render</span><span class="p">(</span><span class="n">markdown_text</span><span class="p">).</span><span class="nf">html_safe</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>Finally, render the markdown file in a Rails view!</p>

<p>For TailwindCSS, wrap it in <code class="language-plaintext highlighter-rouge">class="prose"</code>.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- render README.md in any view --&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"prose"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;</span><span class="err">%=</span> <span class="na">markdown_to_html</span><span class="err">(</span><span class="na">File.read</span><span class="err">(</span><span class="na">Rails.root.join</span><span class="err">('</span><span class="na">README.md</span><span class="err">')))</span> <span class="err">%</span><span class="nt">&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>Learn more about <a href="/markdown">Markdown with Redcarpet</a> and <a href="/markdown-styling-with-rouge">Code syntax highlighting with gem Rouge</a></p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="rails" /><category term="markdown" /><summary type="html"><![CDATA[I love this new era of ultra-simplified websites.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Use Vimeo API with Ruby on Rails</title><link href="https://blog.superails.com/vimeo-api" rel="alternate" type="text/html" title="Use Vimeo API with Ruby on Rails" /><published>2024-10-24T00:00:00+00:00</published><updated>2024-10-24T00:00:00+00:00</updated><id>https://blog.superails.com/vimeo-api</id><content type="html" xml:base="https://blog.superails.com/vimeo-api"><![CDATA[<p>I host SupeRails PRO videos on Vimeo.</p>

<p>It is great because:</p>

<ul>
  <li>no youtube branding &amp; ads =&gt; more premium feeling</li>
  <li>I don’t have to think about hosting my videos &amp; styling my player</li>
  <li>has basic security features, as “video can be viewed only on selected domains”</li>
  <li>fixed cost, not usage based like <a href="https://www.mux.com/">MUX.com</a></li>
</ul>

<p>But having hundreds of videos in my library I want an easy way to import videos from Vimeo into my Ruby on Rails app.</p>

<p>I did not feel comfortable using an Vimeo API gems, but their own API is very good. <strong>You don’t need an api gem to use an API!</strong></p>

<h3 id="1-get-a-vimeo-api-key">1. Get a Vimeo API key</h3>

<p><a href="https://developer.vimeo.com/apps/new">Create a Vimeo app</a></p>

<p>Get an API key</p>

<p><img src="/assets/images/vimeo-api-authentication.png" alt="vimeo api authentication" /></p>

<p>Great! Now you can interact vie the API.</p>

<h3 id="2-use-the-vimeo-api-with-ruby">2. Use the Vimeo API with Ruby:</h3>

<ul>
  <li><a href="https://developer.vimeo.com/api/reference">General Vimeo API docs</a></li>
  <li><a href="https://developer.vimeo.com/api/reference/videos#get_video">GET videos/:id</a></li>
</ul>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># bundle add faraday</span>

<span class="no">ACCESS_TOKEN</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">credentials</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="ss">:vimeo</span><span class="p">,</span> <span class="ss">:access_token</span><span class="p">)</span>

<span class="n">conn</span> <span class="o">=</span> <span class="no">Faraday</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">url: </span><span class="s1">'https://api.vimeo.com'</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">faraday</span><span class="o">|</span>
  <span class="n">faraday</span><span class="p">.</span><span class="nf">request</span> <span class="ss">:authorization</span><span class="p">,</span> <span class="ss">:Bearer</span><span class="p">,</span> <span class="no">ACCESS_TOKEN</span>
<span class="k">end</span>

<span class="c1"># ping the API</span>
<span class="n">response</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="s2">"/me"</span><span class="p">)</span>

<span class="n">body</span> <span class="o">=</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="nf">body</span><span class="p">)</span>
<span class="n">user_path</span> <span class="o">=</span> <span class="n">body</span><span class="p">[</span><span class="s1">'uri'</span><span class="p">]</span>
<span class="c1"># "/users/235423523"</span>

<span class="c1"># get a list of all your videos</span>
<span class="n">response</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="s2">"/me/videos"</span><span class="p">)</span>

<span class="c1"># get list of all videos of a user (25 per page)</span>
<span class="n">response</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="s2">"</span><span class="si">#{</span><span class="n">user_path</span><span class="si">}</span><span class="s2">/videos"</span><span class="p">)</span>

<span class="n">body</span> <span class="o">=</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="nf">body</span><span class="p">)</span>
<span class="c1"># get first video</span>
<span class="n">video</span> <span class="o">=</span> <span class="n">body</span><span class="p">[</span><span class="s1">'data'</span><span class="p">].</span><span class="nf">first</span>
<span class="n">video</span><span class="p">[</span><span class="s1">'duration'</span><span class="p">]</span>
<span class="c1"># 501 (seconds)</span>

<span class="c1"># get video by id</span>
<span class="c1"># https://vimeo.com/1021521307/832cd4eee7</span>
<span class="n">response</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="s2">"videos/45345435"</span><span class="p">)</span>

<span class="k">raise</span> <span class="s2">"Failed to fetch video data: </span><span class="si">#{</span><span class="n">response</span><span class="p">.</span><span class="nf">message</span><span class="si">}</span><span class="s2">"</span> <span class="k">unless</span> <span class="n">response</span><span class="p">.</span><span class="nf">success?</span>

<span class="n">body</span> <span class="o">=</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="nf">body</span><span class="p">)</span>
<span class="n">body</span><span class="p">[</span><span class="s1">'duration'</span><span class="p">]</span> <span class="c1"># 501 (seconds)</span>
</code></pre></div></div>

<p>That’s it! You can now get all videos, or get a video payload based on an ID.</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="ruby" /><category term="rails" /><category term="youtube" /><category term="api" /><category term="vimeo" /><summary type="html"><![CDATA[I host SupeRails PRO videos on Vimeo.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Sentiment Analysis. Analyze Udemy course reviews</title><link href="https://blog.superails.com/sentiment-analysis" rel="alternate" type="text/html" title="Sentiment Analysis. Analyze Udemy course reviews" /><published>2024-10-19T00:00:00+00:00</published><updated>2024-10-19T00:00:00+00:00</updated><id>https://blog.superails.com/sentiment-analysis</id><content type="html" xml:base="https://blog.superails.com/sentiment-analysis"><![CDATA[<p>I <a href="https://www.udemy.com/user/ya-shm/">used to</a> create Udemy courses.</p>

<p>Now I downlaoded all my Udemy reviews and ran a sentimnent analysis on them using gem <a href="https://github.com/7compass/sentimental">7compass/sentimental</a></p>

<p>Here’s how I ran it, and here are the results:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">require</span> <span class="s1">'csv'</span>

<span class="n">file_path</span> <span class="o">=</span> <span class="s2">"db/data/Udemy_Reviews_Export_2024-10-20_20-13-07.csv"</span>

<span class="c1"># CSV: get an array of all ratings</span>

<span class="n">ratings</span> <span class="o">=</span> <span class="p">[]</span>

<span class="no">CSV</span><span class="p">.</span><span class="nf">foreach</span><span class="p">(</span><span class="n">file_path</span><span class="p">,</span> <span class="ss">headers: </span><span class="kp">true</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">row</span><span class="o">|</span>
  <span class="n">rating</span> <span class="o">=</span> <span class="n">row</span><span class="p">[</span><span class="s1">'Rating'</span><span class="p">]</span>
  <span class="n">ratings</span> <span class="o">&lt;&lt;</span> <span class="n">rating</span><span class="p">.</span><span class="nf">to_f</span> <span class="k">unless</span> <span class="n">rating</span><span class="p">.</span><span class="nf">nil?</span>
<span class="k">end</span>

<span class="n">average_rating</span> <span class="o">=</span> <span class="n">ratings</span><span class="p">.</span><span class="nf">sum</span> <span class="o">/</span> <span class="n">ratings</span><span class="p">.</span><span class="nf">size</span>

<span class="n">average_rating</span><span class="p">.</span><span class="nf">round</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>

<span class="c1"># CSV: get an array of all comments</span>

<span class="n">comments</span> <span class="o">=</span> <span class="p">[]</span>

<span class="no">CSV</span><span class="p">.</span><span class="nf">foreach</span><span class="p">(</span><span class="n">file_path</span><span class="p">,</span> <span class="ss">headers: </span><span class="kp">true</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">row</span><span class="o">|</span>
  <span class="n">comment</span> <span class="o">=</span> <span class="n">row</span><span class="p">[</span><span class="s1">'Comment'</span><span class="p">]</span>
  <span class="n">comments</span> <span class="o">&lt;&lt;</span> <span class="n">comment</span> <span class="k">unless</span> <span class="n">comment</span><span class="p">.</span><span class="nf">nil?</span> <span class="o">||</span> <span class="n">comment</span><span class="p">.</span><span class="nf">strip</span><span class="p">.</span><span class="nf">empty?</span>
<span class="k">end</span>

<span class="c1"># Get average sentiment of comments</span>

<span class="n">sentiment_counts</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">positive: </span><span class="mi">0</span><span class="p">,</span> <span class="ss">negative: </span><span class="mi">0</span><span class="p">,</span> <span class="ss">neutral: </span><span class="mi">0</span> <span class="p">}</span>

<span class="n">comments</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">comment</span><span class="o">|</span>
  <span class="n">sentiment</span> <span class="o">=</span> <span class="n">analyzer</span><span class="p">.</span><span class="nf">sentiment</span><span class="p">(</span><span class="n">comment</span><span class="p">)</span>
  <span class="n">sentiment_counts</span><span class="p">[</span><span class="n">sentiment</span><span class="p">]</span> <span class="o">+=</span> <span class="mi">1</span>
<span class="k">end</span>

<span class="c1"># sentiment_counts</span>
<span class="c1"># =&gt; {:positive=&gt;87, :negative=&gt;16, :neutral=&gt;10}</span>

<span class="c1"># Get average sentiment score of comments</span>

<span class="n">total_score</span> <span class="o">=</span> <span class="mf">0.0</span>

<span class="n">comments</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">comment</span><span class="o">|</span>
  <span class="n">score</span> <span class="o">=</span> <span class="n">analyzer</span><span class="p">.</span><span class="nf">score</span><span class="p">(</span><span class="n">comment</span><span class="p">)</span>
  <span class="n">total_score</span> <span class="o">+=</span> <span class="n">score</span>
<span class="k">end</span>

<span class="n">average_score</span> <span class="o">=</span> <span class="n">total_score</span> <span class="o">/</span> <span class="n">comments</span><span class="p">.</span><span class="nf">size</span>

<span class="c1"># average_score</span>
<span class="c1"># =&gt; 1.0640205752212393</span>
</code></pre></div></div>

<p>I’m quite happy with the results!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="sentiment-analysis" /><summary type="html"><![CDATA[I used to create Udemy courses.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Authentication Zero vs Devise</title><link href="https://blog.superails.com/authentication-zero" rel="alternate" type="text/html" title="Authentication Zero vs Devise" /><published>2024-10-18T00:00:00+00:00</published><updated>2024-10-18T00:00:00+00:00</updated><id>https://blog.superails.com/authentication-zero</id><content type="html" xml:base="https://blog.superails.com/authentication-zero"><![CDATA[<p>Why some people don’t like gem Devise:</p>

<ol>
  <li>“feels too much like magic”</li>
  <li>“hard to customize/override defaults”</li>
  <li>“hard to add 2FA” (2 Factor Authentication)</li>
  <li>“not session-based” (<code class="language-plaintext highlighter-rouge">User has_many :sessions</code>)</li>
</ol>

<p>The Rails 8 authentication generator is supposed to address these particular claims.</p>

<p>The generator adds all the authentication code directly into your app. You will have a bunch of auth code to maintain and be responsible for.</p>

<p>But the Rails 8 auth generator <strong>is currently very limited</strong>.</p>

<p>The generator lacks at least:</p>

<ul>
  <li>basic test generators</li>
  <li>registrations</li>
  <li>email confirmations</li>
</ul>

<p>While the authenitcation generator is being actively developed and iterated on, I do not currently recommend it for production use. At least not until <code class="language-plaintext highlighter-rouge">Rails 8.1</code> is released.</p>

<h3 id="lazaronixonauthentication-zero"><a href="https://github.com/lazaronixon/authentication-zero">lazaronixon/authentication-zero</a></h3>

<p>Authentication-zero is a much more <strong>mature</strong> version of what “Rails 8 authentication generator” strives to be.</p>

<p>It covers all the pitfalls of Devise &amp; Rails 8 Authentication. <strong><em>It even has 2FA out of the box!!!</em></strong></p>

<p>Even if you are not planning to implement a new authentication solution, I recommend you run the generators on a new Rails app and study the code. It is an elegant learning source!</p>

<p>The default authentication flow in authentication-zero is somewhat different from Devise. For example:</p>

<ul>
  <li>When a user creates an account, there is a confirmation email sent out straight away. User is <code class="language-plaintext highlighter-rouge">verified:false</code>.</li>
  <li>There is no built in flow to “re-send confirmation instructions “.</li>
  <li>It is up to the developer to implement flows like “restrict unverified access totally or after X time”</li>
</ul>

<p>So, if you really want to “own” your authentication, <a href="https://github.com/lazaronixon/authentication-zero">lazaronixon/authentication-zero</a> is a great starting point! 🚀🚀</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="authentication" /><category term="devise" /><summary type="html"><![CDATA[Why some people don’t like gem Devise:]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hotwire Native Bridge Form Component with Rails</title><link href="https://blog.superails.com/hotwire-native-form-component" rel="alternate" type="text/html" title="Hotwire Native Bridge Form Component with Rails" /><published>2024-10-15T00:00:00+00:00</published><updated>2024-10-15T00:00:00+00:00</updated><id>https://blog.superails.com/hotwire-native-form-component</id><content type="html" xml:base="https://blog.superails.com/hotwire-native-form-component"><![CDATA[<p>The demo app offers a Bridge Form example.</p>

<p>Basically it hides the web “submit” button, and shows a native one in the top-right corner of your mobile screen.</p>

<p>The source files for the Bridge form:</p>
<ul>
  <li><a href="https://github.com/hotwired/hotwire-native-ios/blob/main/Demo/Bridge/FormComponent.swift">iOS/FormComponent.swift</a></li>
  <li><a href="https://github.com/hotwired/hotwire-native-demo/blob/main/public/javascript/controllers/bridge/form_controller.js">StimulusJS form_controller.js</a></li>
  <li><a href="https://github.com/hotwired/hotwire-native-demo/blob/main/views/bridge-form.ejs">HTML example of using this form</a></li>
</ul>

<p>Applying this stimulus controller to a Rails form would look like this:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="sx">%= form_with(model: User.new,
              data: { controller: "bridge--form",
                      action: "turbo:submit-start-&gt;bridge--form#submitStart turbo:submit-end-&gt;bridge--form#submitEnd" },
              class: "contents") do |form| %&gt;
  &lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">text_field</span> <span class="ss">:first_name</span> <span class="o">%&gt;</span>
  <span class="o">&lt;</span><span class="sx">%= form.text_field :last_name_name %&gt;
  &lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">submit</span> <span class="ss">data: </span><span class="p">{</span> <span class="s2">"bridge--form-target"</span><span class="p">:</span> <span class="s2">"submit"</span> <span class="p">},</span> <span class="ss">class: </span><span class="s2">"bg-blue-600"</span> <span class="o">%&gt;</span>
<span class="o">&lt;</span><span class="sx">% end </span><span class="o">&gt;</span>
</code></pre></div></div>

<p>To make it more reusable, you can abstract the application of these data attributes to a form helper:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># rails/app/helpers/form_helper.rb</span>
<span class="c1"># source: https://github.com/joemasilotti/daily-log/blob/main/rails/app/helpers/form_helper.rb</span>
<span class="k">module</span> <span class="nn">FormHelper</span>
  <span class="k">class</span> <span class="nc">BridgeFormBuilder</span> <span class="o">&lt;</span> <span class="no">ActionView</span><span class="o">::</span><span class="no">Helpers</span><span class="o">::</span><span class="no">FormBuilder</span>
    <span class="k">def</span> <span class="nf">submit</span><span class="p">(</span><span class="n">value</span> <span class="o">=</span> <span class="kp">nil</span><span class="p">,</span> <span class="n">options</span> <span class="o">=</span> <span class="p">{})</span>
      <span class="n">options</span><span class="p">[</span><span class="ss">:data</span><span class="p">]</span> <span class="o">||=</span> <span class="p">{}</span>
      <span class="n">options</span><span class="p">[</span><span class="s2">"data-bridge--form-target"</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"submit"</span>
      <span class="n">options</span><span class="p">[</span><span class="ss">:class</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span><span class="n">options</span><span class="p">[</span><span class="ss">:class</span><span class="p">],</span> <span class="s2">"turbo-native:hidden"</span><span class="p">].</span><span class="nf">compact</span>
      <span class="k">super</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">options</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">bridge_form_with</span><span class="p">(</span><span class="o">*</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
    <span class="n">options</span><span class="p">[</span><span class="ss">:html</span><span class="p">]</span> <span class="o">||=</span> <span class="p">{}</span>
    <span class="n">options</span><span class="p">[</span><span class="ss">:html</span><span class="p">][</span><span class="ss">:data</span><span class="p">]</span> <span class="o">||=</span> <span class="p">{}</span>
    <span class="n">options</span><span class="p">[</span><span class="ss">:html</span><span class="p">][</span><span class="ss">:data</span><span class="p">]</span> <span class="o">=</span> <span class="n">options</span><span class="p">[</span><span class="ss">:html</span><span class="p">][</span><span class="ss">:data</span><span class="p">].</span><span class="nf">merge</span><span class="p">(</span><span class="n">bridge_form_data</span><span class="p">)</span>

    <span class="n">options</span><span class="p">[</span><span class="ss">:builder</span><span class="p">]</span> <span class="o">=</span> <span class="no">BridgeFormBuilder</span>

    <span class="n">form_with</span><span class="p">(</span><span class="o">*</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">bridge_form_data</span>
    <span class="p">{</span>
      <span class="ss">controller: </span><span class="s2">"bridge--form"</span><span class="p">,</span>
      <span class="ss">action: </span><span class="s2">"turbo:submit-start-&gt;bridge--form#submitStart turbo:submit-end-&gt;bridge--form#submitEnd"</span>
    <span class="p">}</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Now when you want to have a native “submit” button, you can use <code class="language-plaintext highlighter-rouge">bridge_form_with</code> instead of <code class="language-plaintext highlighter-rouge">form_with</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="sx">%= bridge_form_with(model: User.new, class: "contents") do |form| %&gt;
  &lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">text_field</span> <span class="ss">:first_name</span> <span class="o">%&gt;</span>
  <span class="o">&lt;</span><span class="sx">%= form.text_field :last_name_name %&gt;
  &lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">submit</span> <span class="s2">"Save"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"bg-blue-600"</span> <span class="o">%&gt;</span>
<span class="o">&lt;</span><span class="sx">% end </span><span class="o">%&gt;</span>
</code></pre></div></div>

<p>In some cases you will want to add <code class="language-plaintext highlighter-rouge">, html: {"data-turbo-action": "replace"}</code> to your form. It can make the page refresh/redirect feel better after saving the data.</p>

<p><strong>⚠️ TROUBLESHOOTING</strong></p>

<p><code class="language-plaintext highlighter-rouge">form.submit</code> might or might not need a title to function.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">form</span><span class="p">.</span><span class="nf">submit</span> <span class="s2">"Save"</span>
<span class="n">form</span><span class="p">.</span><span class="nf">submit</span>
</code></pre></div></div>

<p><a href="https://superails.com/pricing">Subscribe to SupeRails.com</a> for more Hotwire Native content!</p>

<p>That’s it for now!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><summary type="html"><![CDATA[The demo app offers a Bridge Form example.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hotwire Native iOS Path Configuration via API</title><link href="https://blog.superails.com/hotwire-native-path-configuration-via-api" rel="alternate" type="text/html" title="Hotwire Native iOS Path Configuration via API" /><published>2024-10-13T00:00:00+00:00</published><updated>2024-10-13T00:00:00+00:00</updated><id>https://blog.superails.com/hotwire-native-path-configuration-via-api</id><content type="html" xml:base="https://blog.superails.com/hotwire-native-path-configuration-via-api"><![CDATA[<p>With Hotwire Native you want to outsource as much logic as possible to your Web app.</p>

<p>Making changes in the Web app is easy.</p>

<p>Making changes in a Native app requires an additional release-review.</p>

<p>The hotwire native demo app has a <code class="language-plaintext highlighter-rouge">path-configuration.json</code> file that controls some navigation patterns.</p>

<p><a href="https://native.hotwired.dev/reference/path-configuration">Official Path Configuration Docs</a></p>

<p>Let’s deliver this file via API from our Web app!</p>

<p>To do this, add a <code class="language-plaintext highlighter-rouge">server</code> url to your pathConfiguration:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># ios/SceneController
<span class="p">private lazy var pathConfiguration = PathConfiguration(sources: [
</span>    .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!),
<span class="gi">+    .server(rootURL.appending(path: "v1/turbo/ios/path_configuration.json"))
</span>])
</code></pre></div></div>

<p>The server source takes precedence over the file source.</p>

<p>If the server source is not accessible, the app will fall back to the file source.</p>

<p>Add a corresponding route in your Rails app:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">draw</span> <span class="k">do</span>
  <span class="n">namespace</span> <span class="ss">:v1</span> <span class="k">do</span>
    <span class="n">namespace</span> <span class="ss">:turbo</span> <span class="k">do</span>
      <span class="n">namespace</span> <span class="ss">:ios</span> <span class="k">do</span>
        <span class="n">resource</span> <span class="ss">:path_configuration</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:show</span><span class="p">]</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Finally render the JSON in your controller action.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir </span>app/controllers/v1
<span class="nb">mkdir </span>app/controllers/v1/turbo
<span class="nb">mkdir </span>app/controllers/v1/turbo/ios
<span class="nb">echo</span> <span class="o">&gt;</span> app/controllers/v1/turbo/ios/path_configurations_controller.rb
</code></pre></div></div>

<p>Be sure that this URL is accessible without restrictions like <code class="language-plaintext highlighter-rouge">authenticate_user!</code>.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/v1/turbo/ios/path_configurations_controller.rb</span>
<span class="c1"># http://localhost:3000/v1/turbo/ios/path_configuration.json</span>
<span class="k">class</span> <span class="nc">V1::Turbo::Ios::PathConfigurationsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="c1"># skip_before_action :authenticate_user!</span>

  <span class="k">def</span> <span class="nf">show</span>
    <span class="n">render</span> <span class="ss">json: </span><span class="p">{</span>
      <span class="s2">"rules"</span><span class="p">:</span> <span class="p">[</span>
        <span class="p">{</span>
          <span class="s2">"patterns"</span><span class="p">:</span> <span class="p">[</span>
            <span class="s2">"/new$"</span><span class="p">,</span>
            <span class="s2">"/edit$"</span><span class="p">,</span>
          <span class="p">],</span>
          <span class="s2">"properties"</span><span class="p">:</span> <span class="p">{</span>
            <span class="s2">"context"</span><span class="p">:</span> <span class="s2">"modal"</span>
          <span class="p">}</span>
        <span class="p">},</span>
        <span class="p">{</span>
          <span class="s2">"patterns"</span><span class="p">:</span> <span class="p">[</span>
            <span class="s2">"^/users/edit$"</span>
          <span class="p">],</span>
          <span class="s2">"properties"</span><span class="p">:</span> <span class="p">{</span>
            <span class="s2">"context"</span><span class="p">:</span> <span class="s2">"default"</span>
          <span class="p">}</span>
        <span class="p">}</span>
      <span class="p">]</span>
    <span class="p">}</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Adding more advanced behaviours:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># app/controllers/v1/turbo/ios/path_configurations_controller.rb
<span class="p">class V1::Turbo::Ios::PathConfigurationsController &lt; ApplicationController
</span>  def show
    render json: {
<span class="gi">+      "settings": {
+        "screenshots_enabled": true
+      },
</span>      "rules": [
        {
          "patterns": [
            "/new$",
            "/edit$",
          ],
          "properties": {
            "context": "modal"
          }
        },
        {
          "patterns": [
            "^/users/edit$"
          ],
          "properties": {
            "context": "default"
          }
        },
<span class="gi">+        {
+          "patterns": [
+            "/pricing$"
+          ],
+          "properties": {
+            "pull_to_refresh_enabled": false
+          }
+        }
</span>      ]
    }
  end
<span class="p">end
</span></code></pre></div></div>

<p><a href="https://superails.com/pricing">Subscribe to SupeRails.com</a> for more Hotwire Native content!</p>

<p>That’s it for now!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><summary type="html"><![CDATA[With Hotwire Native you want to outsource as much logic as possible to your Web app.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hotwire Native Nav Button with icon</title><link href="https://blog.superails.com/hotwire-native-bridge-button" rel="alternate" type="text/html" title="Hotwire Native Nav Button with icon" /><published>2024-10-12T00:00:00+00:00</published><updated>2024-10-12T00:00:00+00:00</updated><id>https://blog.superails.com/hotwire-native-bridge-button</id><content type="html" xml:base="https://blog.superails.com/hotwire-native-bridge-button"><![CDATA[<p>The <a href="https://native.hotwired.dev/ios/bridge-components">Hotwire Native Bridge Components docs</a> demonstrate using a Button Component.</p>

<p>The button is always presented as clickable <strong>text</strong>.</p>

<p><img src="/assets/images/hotwire-native-button-text.png" alt="hotwire-native-button-text" /></p>

<p>But to turn it into a clickable <strong>icon</strong>, we would have to do some modifications/extend our component.</p>

<p><img src="/assets/images/hotwire-native-button-icon.png" alt="hotwire-native-button-icon" /></p>

<p>We will extend the button from the example to also:</p>
<ul>
  <li>display icon instead of text</li>
  <li>place icon on right or left</li>
</ul>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ios/ButtonComponent.swift</span>
<span class="kd">import</span> <span class="kt">HotwireNative</span>
<span class="kd">import</span> <span class="kt">UIKit</span>

<span class="kd">final</span> <span class="kd">class</span> <span class="kt">ButtonComponent</span><span class="p">:</span> <span class="kt">BridgeComponent</span> <span class="p">{</span>
    <span class="k">override</span> <span class="kd">class</span> <span class="k">var</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span> <span class="s">"button"</span> <span class="p">}</span>

    <span class="k">override</span> <span class="kd">func</span> <span class="nf">onReceive</span><span class="p">(</span><span class="nv">message</span><span class="p">:</span> <span class="kt">Message</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">viewController</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="nf">addButton</span><span class="p">(</span><span class="nv">via</span><span class="p">:</span> <span class="n">message</span><span class="p">,</span> <span class="nv">to</span><span class="p">:</span> <span class="n">viewController</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="k">var</span> <span class="nv">viewController</span><span class="p">:</span> <span class="kt">UIViewController</span><span class="p">?</span> <span class="p">{</span>
        <span class="n">delegate</span><span class="o">.</span><span class="n">destination</span> <span class="k">as?</span> <span class="kt">UIViewController</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">addButton</span><span class="p">(</span><span class="n">via</span> <span class="nv">message</span><span class="p">:</span> <span class="kt">Message</span><span class="p">,</span> <span class="n">to</span> <span class="nv">viewController</span><span class="p">:</span> <span class="kt">UIViewController</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">data</span><span class="p">:</span> <span class="kt">MessageData</span> <span class="o">=</span> <span class="n">message</span><span class="o">.</span><span class="nf">data</span><span class="p">()</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

       <span class="k">let</span> <span class="nv">image</span><span class="p">:</span> <span class="kt">UIImage</span><span class="p">?</span>

       <span class="k">if</span> <span class="k">let</span> <span class="nv">imageName</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="n">image</span> <span class="p">{</span>
           <span class="n">image</span> <span class="o">=</span> <span class="kt">UIImage</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="n">imageName</span><span class="p">)</span>
       <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
           <span class="n">image</span> <span class="o">=</span> <span class="kc">nil</span>
       <span class="p">}</span>

        <span class="k">let</span> <span class="nv">action</span> <span class="o">=</span> <span class="kt">UIAction</span> <span class="p">{</span> <span class="p">[</span><span class="k">unowned</span> <span class="k">self</span><span class="p">]</span> <span class="n">_</span> <span class="k">in</span>
            <span class="k">self</span><span class="o">.</span><span class="nf">reply</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="s">"connect"</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="k">let</span> <span class="nv">item</span> <span class="o">=</span> <span class="kt">UIBarButtonItem</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="n">data</span><span class="o">.</span><span class="n">title</span><span class="p">,</span> <span class="nv">image</span><span class="p">:</span> <span class="n">image</span><span class="p">,</span> <span class="nv">primaryAction</span><span class="p">:</span> <span class="n">action</span><span class="p">)</span>

        <span class="k">if</span> <span class="n">data</span><span class="o">.</span><span class="n">side</span> <span class="o">==</span> <span class="s">"right"</span> <span class="p">{</span>
            <span class="n">viewController</span><span class="o">.</span><span class="n">navigationItem</span><span class="o">.</span><span class="n">rightBarButtonItem</span> <span class="o">=</span> <span class="n">item</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="n">viewController</span><span class="o">.</span><span class="n">navigationItem</span><span class="o">.</span><span class="n">leftBarButtonItem</span> <span class="o">=</span> <span class="n">item</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">private</span> <span class="kd">extension</span> <span class="kt">ButtonComponent</span> <span class="p">{</span>
    <span class="kd">struct</span> <span class="kt">MessageData</span><span class="p">:</span> <span class="kt">Decodable</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">title</span><span class="p">:</span> <span class="kt">String</span>
        <span class="k">let</span> <span class="nv">image</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
        <span class="k">let</span> <span class="nv">side</span><span class="p">:</span> <span class="kt">String</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/controllers/bridge/button_controller.js</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">BridgeComponent</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@hotwired/hotwire-native-bridge</span><span class="dl">"</span>

<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">BridgeComponent</span> <span class="p">{</span>
  <span class="kd">static</span> <span class="nx">component</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">button</span><span class="dl">"</span>

  <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">super</span><span class="p">.</span><span class="nf">connect</span><span class="p">()</span>

    <span class="kd">const</span> <span class="nx">element</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">bridgeElement</span>
    <span class="kd">const</span> <span class="nx">title</span> <span class="o">=</span> <span class="nx">element</span><span class="p">.</span><span class="nf">bridgeAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">title</span><span class="dl">"</span><span class="p">)</span>
    <span class="kd">const</span> <span class="nx">image</span> <span class="o">=</span> <span class="nx">element</span><span class="p">.</span><span class="nf">bridgeAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">ios-image</span><span class="dl">"</span><span class="p">)</span>
    <span class="kd">const</span> <span class="nx">side</span> <span class="o">=</span> <span class="nx">element</span><span class="p">.</span><span class="nf">bridgeAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">side</span><span class="dl">"</span><span class="p">)</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">right</span><span class="dl">"</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="dl">"</span><span class="s2">connect</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span><span class="nx">title</span><span class="p">,</span> <span class="nx">image</span><span class="p">,</span> <span class="nx">side</span><span class="p">},</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">element</span><span class="p">.</span><span class="nf">click</span><span class="p">()</span>
    <span class="p">})</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>With this approach, if you want to use text over image, <strong>leave the image blank</strong>.</p>

<p>You still have to keep the image attribute for the button to render!</p>

<p><strong>Text</strong> button:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"/posts"</span> <span class="na">data-controller=</span><span class="s">"bridge--button"</span> <span class="na">data-bridge-title=</span><span class="s">"Posts"</span><span class="nt">&gt;</span>
  Posts
<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p><strong>Icon</strong> button:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"/posts"</span> <span class="na">data-controller=</span><span class="s">"bridge--button"</span> <span class="na">data-bridge-title=</span><span class="s">"Posts"</span> <span class="na">data-bridge-ios-image=</span><span class="s">"play.circle"</span><span class="nt">&gt;</span>
  Posts
<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p><strong>Icon</strong> button on the <strong>left</strong> (<strong>right</strong> by default):</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"/posts"</span> <span class="na">data-controller=</span><span class="s">"bridge--button"</span> <span class="na">data-bridge-title=</span><span class="s">"Posts"</span> <span class="na">data-bridge-ios-image=</span><span class="s">"play.circle"</span> <span class="na">data-bridge-side=</span><span class="s">"left"</span><span class="nt">&gt;</span>
  Posts
<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p>The Native button will click whatever element you apply the <code class="language-plaintext highlighter-rouge">bridge--button</code> on. It does not have to be a <code class="language-plaintext highlighter-rouge">&lt;a href=""&gt;</code>!</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">data-controller=</span><span class="s">"bridge--button"</span> <span class="na">data-bridge-title=</span><span class="s">"Search"</span> <span class="na">data-bridge-ios-image=</span><span class="s">"magnifyingglass.circle"</span> <span class="na">class=</span><span class="s">"hidden"</span> <span class="na">data-action=</span><span class="s">"click-&gt;dialog#open"</span><span class="nt">&gt;</span>
  Search
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>Hotwire native button clicking a div that triggers JS, not a link:</p>

<p><img src="/assets/images/hotwire-native-btn.gif" alt="Hotwire native button clicking a div that triggers JS, not a link" /></p>

<p><a href="https://superails.com/pricing">Subscribe to SupeRails.com</a> for more Hotwire Native content!</p>

<p>That’s it for now!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><summary type="html"><![CDATA[The Hotwire Native Bridge Components docs demonstrate using a Button Component.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hotwire Native CSS and TailwindCSS variants (conditionals)</title><link href="https://blog.superails.com/hotwire-native-conditional-tailwindcss" rel="alternate" type="text/html" title="Hotwire Native CSS and TailwindCSS variants (conditionals)" /><published>2024-10-11T00:00:00+00:00</published><updated>2024-10-11T00:00:00+00:00</updated><id>https://blog.superails.com/hotwire-native-conditional-tailwindcss</id><content type="html" xml:base="https://blog.superails.com/hotwire-native-conditional-tailwindcss"><![CDATA[<p>When building <a href="https://native.hotwired.dev/">Hotwire Native</a> apps, often you will want to have different CSS for native and desktop apps.</p>

<p>We can achieve it by addin custom variants like <code class="language-plaintext highlighter-rouge">non-turbo-native:</code> &amp; <code class="language-plaintext highlighter-rouge">turbo-native:</code>.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"non-turbo-native:hidden"</span><span class="nt">&gt;</span>
  Visible only on Native
<span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"turbo-native:hidden"</span><span class="nt">&gt;</span>
  Visible only on Desktop
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h3 id="css">CSS</h3>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* application.css */</span>
<span class="nt">body</span><span class="nc">.turbo-native</span> <span class="nc">.turbo-native</span><span class="nd">:hidden</span> <span class="p">{</span>
  <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;body</span> <span class="na">class=</span><span class="s">"&lt;%%= "</span><span class="na">turbo-native</span><span class="err">"</span> <span class="na">if</span> <span class="na">turbo_native_app</span><span class="err">?</span> <span class="err">%</span><span class="nt">&gt;</span>"&gt;
<span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"turbo-native:hidden"</span><span class="nt">&gt;</span>Hello, world!<span class="nt">&lt;/h1&gt;</span>
</code></pre></div></div>

<p>Source: <a href="https://masilotti.com/hide-web-rendered-content-on-turbo-native-apps/">masilotti.com</a></p>

<h3 id="tailwind">Tailwind</h3>

<p>To enable this, update your tailwind config file:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/tailwind.config.js
<span class="p">const defaultTheme = require('tailwindcss/defaultTheme')
</span><span class="gi">+const plugin = require('tailwindcss/plugin')
</span><span class="err">
</span><span class="p">module.exports = {
</span>  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/aspect-ratio'),
    require('@tailwindcss/typography'),
    require('@tailwindcss/container-queries'),
<span class="gi">+    plugin(function({ addVariant }) {
+      addVariant("turbo-native", "html[data-turbo-native] &amp;"),
+      addVariant("non-turbo-native", "html:not([data-turbo-native]) &amp;")
+    })
</span>  ],
}
</code></pre></div></div>

<p>And <strong>conditionally</strong> add <code class="language-plaintext highlighter-rouge">data-turbo-native</code> to your <code class="language-plaintext highlighter-rouge">&lt;html&gt;</code> tag:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/application_helper.rb</span>
<span class="k">module</span> <span class="nn">ApplicationHelper</span>
  <span class="k">def</span> <span class="nf">platform_identifier</span>
    <span class="s1">'data-turbo-native'</span> <span class="k">if</span> <span class="n">turbo_native_app?</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># app/views/application.html.erb
<span class="gd">-&lt;html&gt;
</span><span class="gi">+&lt;html &lt;%= platform_identifier %&gt;&gt;
</span></code></pre></div></div>

<p>That’s it! Now you can apply the CSS variant:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"non-turbo-native:hidden"</span><span class="nt">&gt;</span>
  Visible only on Native
<span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"turbo-native:hidden"</span><span class="nt">&gt;</span>
  Visible only on Desktop
<span class="nt">&lt;/div&gt;</span>

<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"turbo-native:bg-black turbo-native:text-white"</span><span class="nt">&gt;</span>
  Turbo Native
<span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"non-turbo-native:bg-black non-turbo-native:text-white"</span><span class="nt">&gt;</span>
  Non Turbo Native
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h3 id="enable-for-a-block">Enable for a block</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// config/tailwind.config.js</span>
      <span class="nf">addVariant</span><span class="p">(</span><span class="dl">'</span><span class="s1">mobile</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">&amp;[data-turbo-native="true"]</span><span class="dl">'</span><span class="p">),</span>
      <span class="nf">addVariant</span><span class="p">(</span><span class="dl">'</span><span class="s1">non-mobile</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">&amp;[data-turbo-native="false"]</span><span class="dl">'</span><span class="p">),</span>
</code></pre></div></div>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">data-turbo-native=</span><span class="s">"true"</span> <span class="na">class=</span><span class="s">"mobile:bg-black non-mobile:bg-red-400"</span><span class="nt">&gt;</span>
  This is mobile
<span class="nt">&lt;/div&gt;</span>

<span class="nt">&lt;div</span> <span class="na">data-turbo-native=</span><span class="s">"false"</span> <span class="na">class=</span><span class="s">"mobile:bg-black non-mobile:bg-red-400"</span><span class="nt">&gt;</span>
  This is non-mobile
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p><a href="https://superails.com/pricing">Subscribe to SupeRails.com</a> for more Hotwire Native content!</p>

<p>That’s it for now!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><summary type="html"><![CDATA[When building Hotwire Native apps, often you will want to have different CSS for native and desktop apps.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hotwire Native iOS - Tabs and design customisation</title><link href="https://blog.superails.com/hotwire-native-customisation" rel="alternate" type="text/html" title="Hotwire Native iOS - Tabs and design customisation" /><published>2024-10-03T00:00:00+00:00</published><updated>2024-10-03T00:00:00+00:00</updated><id>https://blog.superails.com/hotwire-native-customisation</id><content type="html" xml:base="https://blog.superails.com/hotwire-native-customisation"><![CDATA[<p><img src="/assets/images/hotwire-native-customisation.png" alt="Hotwire Native customised example" /></p>

<h3 id="add-tab-bar">Add Tab bar</h3>

<p>I think it’s one of the most requested/improtant features for a classic Rails app that is turned mobile.</p>

<p>Tabs behave like browser tabs = navigation history is separete within each tab.</p>

<p>The tab titles will be overriden by the page HTML <code class="language-plaintext highlighter-rouge">&lt;title&gt;</code> if present.</p>

<p>Download and use SF Symbols app to select icons that work best for you.</p>

<p>The best boilerplate to start building a Native app is the <a href="https://github.com/hotwired/hotwire-native-ios/tree/main/Demo">Demo app</a>.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Demo/SceneController.swift</span>
<span class="kd">class</span> <span class="nc">TabBarController</span><span class="p">:</span> <span class="nx">UITabBarController</span> <span class="p">{</span>
    <span class="kr">private</span> <span class="kd">let</span> <span class="nx">navigators</span><span class="p">:</span> <span class="p">[</span><span class="nx">Navigator</span><span class="p">]</span>
    
    <span class="nf">init</span><span class="p">(</span><span class="nx">navigators</span><span class="p">:</span> <span class="p">[</span><span class="nx">Navigator</span><span class="p">])</span> <span class="p">{</span>
        <span class="nb">self</span><span class="p">.</span><span class="nx">navigators</span> <span class="o">=</span> <span class="nx">navigators</span>
        <span class="k">super</span><span class="p">.</span><span class="nf">init</span><span class="p">(</span><span class="nx">nibName</span><span class="p">:</span> <span class="nx">nil</span><span class="p">,</span> <span class="nx">bundle</span><span class="p">:</span> <span class="nx">nil</span><span class="p">)</span>
        
        <span class="nx">viewControllers</span> <span class="o">=</span> <span class="nx">navigators</span><span class="p">.</span><span class="nx">map</span> <span class="p">{</span> <span class="nx">$0</span><span class="p">.</span><span class="nx">rootViewController</span> <span class="p">}</span>
        
        <span class="c1">// Customize tab bar items</span>
        <span class="nx">viewControllers</span><span class="p">?[</span><span class="mi">0</span><span class="p">].</span><span class="nx">tabBarItem</span> <span class="o">=</span> <span class="nc">UITabBarItem</span><span class="p">(</span><span class="nx">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Home</span><span class="dl">"</span><span class="p">,</span> <span class="nx">image</span><span class="p">:</span> <span class="nc">UIImage</span><span class="p">(</span><span class="nx">systemName</span><span class="p">:</span> <span class="dl">"</span><span class="s2">house</span><span class="dl">"</span><span class="p">),</span> <span class="nx">tag</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span>
        <span class="nx">viewControllers</span><span class="p">?[</span><span class="mi">1</span><span class="p">].</span><span class="nx">tabBarItem</span> <span class="o">=</span> <span class="nc">UITabBarItem</span><span class="p">(</span><span class="nx">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Posts</span><span class="dl">"</span><span class="p">,</span> <span class="nx">image</span><span class="p">:</span> <span class="nc">UIImage</span><span class="p">(</span><span class="nx">systemName</span><span class="p">:</span> <span class="dl">"</span><span class="s2">play.circle</span><span class="dl">"</span><span class="p">),</span> <span class="nx">tag</span><span class="p">:</span> <span class="mi">1</span><span class="p">)</span>
        <span class="nx">viewControllers</span><span class="p">?[</span><span class="mi">2</span><span class="p">].</span><span class="nx">tabBarItem</span> <span class="o">=</span> <span class="nc">UITabBarItem</span><span class="p">(</span><span class="nx">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Playlists</span><span class="dl">"</span><span class="p">,</span> <span class="nx">image</span><span class="p">:</span> <span class="nc">UIImage</span><span class="p">(</span><span class="nx">systemName</span><span class="p">:</span> <span class="dl">"</span><span class="s2">list.number</span><span class="dl">"</span><span class="p">),</span> <span class="nx">tag</span><span class="p">:</span> <span class="mi">2</span><span class="p">)</span>
    <span class="p">}</span>
    
    <span class="nx">required</span> <span class="nx">init</span><span class="p">?(</span><span class="nx">coder</span><span class="p">:</span> <span class="nx">NSCoder</span><span class="p">)</span> <span class="p">{</span>
        <span class="nf">fatalError</span><span class="p">(</span><span class="dl">"</span><span class="s2">init(coder:) has not been implemented</span><span class="dl">"</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Assuming you have paths <code class="language-plaintext highlighter-rouge">/posts</code> &amp; <code class="language-plaintext highlighter-rouge">/playlists</code> in your app:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Demo/SceneController.swift
<span class="gd">-    private lazy var navigator = Navigator(pathConfiguration: pathConfiguration, delegate: self)
</span><span class="gi">+    private lazy var navigators: [Navigator] = {
+        (0..&lt;3).map { _ in Navigator(pathConfiguration: pathConfiguration, delegate: self) }
+    }()
+    private lazy var tabBarController = TabBarController(navigators: navigators)
</span><span class="err">
</span>...
<span class="err">
</span><span class="gd">-        navigator.route(rootURL)
</span><span class="gi">+        navigators[0].route(rootURL)
+        navigators[1].route(rootURL.appendingPathComponent("/posts"))
+        navigators[2].route(rootURL.appendingPathComponent("/playlists"))
</span><span class="err">
</span>...
<span class="err">
</span><span class="gd">-        window.rootViewController = navigator.rootViewController
</span><span class="gi">+        window.rootViewController = tabBarController
</span></code></pre></div></div>

<p>⚠️ You might have to delete the example code for “Authentication” and “Numbers”, so that no errors pop up.</p>

<h3 id="customize-tab-bar-design">Customize tab bar design</h3>

<p>Use <a href="https://www.uicolor.io/">uicolor.io</a> to convert Hex colors to Swift.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Demo/SceneController.swift</span>
<span class="nx">extension</span> <span class="nx">UITabBar</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="nx">func</span> <span class="nf">configureWithOpaqueBackground</span><span class="p">()</span> <span class="p">{</span>
        <span class="kd">let</span> <span class="nx">tabBarAppearance</span> <span class="o">=</span> <span class="nc">UITabBarAppearance</span><span class="p">()</span>
        <span class="nx">tabBarAppearance</span><span class="p">.</span><span class="nf">configureWithOpaqueBackground</span><span class="p">()</span>
        
        <span class="c1">// tabBarAppearance.backgroundColor = .systemGray5</span>
        <span class="nx">tabBarAppearance</span><span class="p">.</span><span class="nx">backgroundColor</span> <span class="o">=</span> <span class="nc">UIColor</span><span class="p">(</span><span class="nx">red</span><span class="p">:</span> <span class="mf">0.09</span><span class="p">,</span> <span class="nx">green</span><span class="p">:</span> <span class="mf">0.11</span><span class="p">,</span> <span class="nx">blue</span><span class="p">:</span> <span class="mf">0.13</span><span class="p">,</span> <span class="nx">alpha</span><span class="p">:</span> <span class="mf">1.00</span><span class="p">)</span>
        
        <span class="nf">appearance</span><span class="p">().</span><span class="nx">standardAppearance</span> <span class="o">=</span> <span class="nx">tabBarAppearance</span>
        <span class="nf">appearance</span><span class="p">().</span><span class="nx">scrollEdgeAppearance</span> <span class="o">=</span> <span class="nx">tabBarAppearance</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Demo/SceneController.swift
<span class="gi">+  UITabBar.configureWithOpaqueBackground()
</span>  window.rootViewController = tabBarController
</code></pre></div></div>

<h3 id="customize-header">Customize header</h3>

<ul>
  <li>Make header not transparent</li>
  <li>Change background color</li>
  <li>Change text color</li>
</ul>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Demo/SceneController.swift</span>
<span class="nx">extension</span> <span class="nx">UINavigationBar</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="nx">func</span> <span class="nf">configureWithOpaqueBackground</span><span class="p">()</span> <span class="p">{</span>
        <span class="kd">let</span> <span class="nx">navigationBarAppearance</span> <span class="o">=</span> <span class="nc">UINavigationBarAppearance</span><span class="p">()</span>
        <span class="nx">navigationBarAppearance</span><span class="p">.</span><span class="nf">configureWithOpaqueBackground</span><span class="p">()</span>
        <span class="c1">// navigationBarAppearance.backgroundColor = .systemBlue</span>
        <span class="nx">navigationBarAppearance</span><span class="p">.</span><span class="nx">backgroundColor</span> <span class="o">=</span> <span class="nc">UIColor</span><span class="p">(</span><span class="nx">red</span><span class="p">:</span> <span class="mf">0.09</span><span class="p">,</span> <span class="nx">green</span><span class="p">:</span> <span class="mf">0.11</span><span class="p">,</span> <span class="nx">blue</span><span class="p">:</span> <span class="mf">0.13</span><span class="p">,</span> <span class="nx">alpha</span><span class="p">:</span> <span class="mf">1.00</span><span class="p">)</span>
        <span class="nx">navigationBarAppearance</span><span class="p">.</span><span class="nx">titleTextAttributes</span> <span class="o">=</span> <span class="p">[.</span><span class="nx">foregroundColor</span><span class="p">:</span> <span class="nx">UIColor</span><span class="p">.</span><span class="nx">white</span><span class="p">]</span>
        <span class="nx">navigationBarAppearance</span><span class="p">.</span><span class="nx">largeTitleTextAttributes</span> <span class="o">=</span> <span class="p">[.</span><span class="nx">foregroundColor</span><span class="p">:</span> <span class="nx">UIColor</span><span class="p">.</span><span class="nx">white</span><span class="p">]</span>

        <span class="nf">appearance</span><span class="p">().</span><span class="nx">scrollEdgeAppearance</span> <span class="o">=</span> <span class="nx">navigationBarAppearance</span>
        <span class="nf">appearance</span><span class="p">().</span><span class="nx">standardAppearance</span> <span class="o">=</span> <span class="nx">navigationBarAppearance</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>above</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Demo/SceneController.swift
<span class="gi">+  UINavigationBar.configureWithOpaqueBackground()
</span>  window.rootViewController = tabBarController
</code></pre></div></div>

<h3 id="disable-force-touch">Disable force touch</h3>

<p>Force Touch = press and hold a link for a long time to open it as a preview in an in-app browser. This feels like a browser, not app behaviour.</p>

<p><img src="/assets/images/hotwire-native-force-touch-example.png" alt="Hotwire Native force touch example" /></p>

<p>Let’s disable it.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Demo/SceneController.swift
...
        configureBridge()
<span class="gi">+        Hotwire.config.makeCustomWebView = { configuration in
+            let webView = WKWebView(frame: .zero, configuration: configuration)
+            webView.allowsLinkPreview = false
+            Bridge.initialize(webView)
+            return webView
+        }
</span>        configureRootViewController()
...
</code></pre></div></div>

<p><a href="https://superails.com/pricing">Subscribe to SupeRails.com</a> for more Hotwire Native content!</p>

<p>That’s it for now!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="hotwire-native" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Search and Autocomplete UK Company Information</title><link href="https://blog.superails.com/uk-company-database-search" rel="alternate" type="text/html" title="Search and Autocomplete UK Company Information" /><published>2024-08-27T00:00:00+00:00</published><updated>2024-08-27T00:00:00+00:00</updated><id>https://blog.superails.com/uk-company-database-search</id><content type="html" xml:base="https://blog.superails.com/uk-company-database-search"><![CDATA[<p>Previousl I wrote about <a href="/french-company-database-search">finding a company in the French national company database</a>.</p>

<p>Now let’s <strong>search 🇬🇧UK companies via API</strong>.</p>

<p>The UK government nicely provides a free API.</p>

<p><a href="https://developer-specs.company-information.service.gov.uk/companies-house-public-data-api/reference">API Docs</a>.</p>

<p>You can <a href="https://developer.company-information.service.gov.uk">create a free account</a> to get an API token.</p>

<p><img src="/assets/images/uk-company-search.png" alt="UK company search - get API key" /></p>

<p>Here’s a job that let’s you search for company details. The result is most accurate if you input the national company identifier.</p>

<p>To perform the search with your API key, you will need to encode it with Base64!</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-H</span> <span class="s2">"Authorization: Basic MyApiKey"</span> <span class="s2">"https://api.company-information.service.gov.uk/search/companies?q=skibby"</span>
<span class="c"># =&gt; {"error":"Invalid Authorization","type":"ch:service"}%</span>
<span class="nb">echo</span> <span class="nt">-n</span> MyApiKey | <span class="nb">base64</span>
<span class="c"># =&gt; MyEncodedApiKey</span>
curl <span class="nt">-H</span> <span class="s2">"Authorization: Basic MyEncodedApiKey"</span> <span class="s2">"https://api.company-information.service.gov.uk/search/companies?q=skibby"</span>
<span class="c"># =&gt; success</span>
</code></pre></div></div>

<p>Example with a Rails job:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># API DOCS: PAYLOAD EXAMPLE</span>
<span class="c1"># https://developer-specs.company-information.service.gov.uk/companies-house-public-data-api/resources/companysearch?v=latest</span>

<span class="c1"># rails g job UkCompanySearch</span>
<span class="c1"># bundle add faraday</span>
<span class="c1"># result = UkCompanySearchJob.perform_now('skibby')</span>
<span class="k">class</span> <span class="nc">UkCompanySearchJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
  <span class="n">queue_as</span> <span class="ss">:default</span>
  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
    <span class="n">api_key</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">credentials</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="ss">:company_search</span><span class="p">,</span> <span class="ss">:gb</span><span class="p">)</span>
    <span class="n">url</span> <span class="o">=</span> <span class="s2">"https://api.company-information.service.gov.uk/search/companies?q=</span><span class="si">#{</span><span class="n">query</span><span class="si">}</span><span class="s2">"</span>

    <span class="n">connection</span> <span class="o">=</span> <span class="no">Faraday</span><span class="p">.</span><span class="nf">new</span> <span class="k">do</span> <span class="o">|</span><span class="n">conn</span><span class="o">|</span>
      <span class="n">conn</span><span class="p">.</span><span class="nf">headers</span><span class="p">[</span><span class="s2">"Authorization"</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"Basic </span><span class="si">#{</span><span class="no">Base64</span><span class="p">.</span><span class="nf">strict_encode64</span><span class="p">(</span><span class="n">api_key</span> <span class="o">+</span> <span class="s2">":"</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span>
      <span class="n">conn</span><span class="p">.</span><span class="nf">adapter</span> <span class="no">Faraday</span><span class="p">.</span><span class="nf">default_adapter</span>
    <span class="k">end</span>

    <span class="n">response</span> <span class="o">=</span> <span class="n">connection</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">response</span><span class="p">.</span><span class="nf">success?</span>
      <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="nf">body</span><span class="p">)</span>
    <span class="k">else</span>
      <span class="p">{</span><span class="ss">error: </span><span class="n">response</span><span class="p">.</span><span class="nf">status</span><span class="p">,</span> <span class="ss">message: </span><span class="n">response</span><span class="p">.</span><span class="nf">reason_phrase</span><span class="p">}</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Well, that’s it!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="uk" /><category term="open-data" /><category term="company-search" /><summary type="html"><![CDATA[Previousl I wrote about finding a company in the French national company database.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Add mentions to a text field with TributeJS</title><link href="https://blog.superails.com/create-mentions-tribute-js" rel="alternate" type="text/html" title="Add mentions to a text field with TributeJS" /><published>2024-08-25T00:00:00+00:00</published><updated>2024-08-25T00:00:00+00:00</updated><id>https://blog.superails.com/create-mentions-tribute-js</id><content type="html" xml:base="https://blog.superails.com/create-mentions-tribute-js"><![CDATA[<p>Previously I wrote about <a href="/search-by-hashtags-or-mentions">parsing #tags and @mentions</a>.</p>

<p>Now, let’s create mentions (find users and mention them).</p>

<p>Recently I added mentions to SupeRails. Now users can tag each other by Github username. A tagged user will see that he was mentioned.</p>

<p><a href="https://zurb.com/playground/tribute">TributeJS</a> is a good plugin for adding mentions.</p>

<p>We will use <a href="https://github.com/rails/requestjs-rails">requestjs-rails</a> to make internal GET requests with JS (to get a list of usernames).</p>

<p>Initial setup:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails new mentionsapp <span class="nt">--main</span> <span class="nt">-d</span><span class="o">=</span>postgresql <span class="nt">-c</span><span class="o">=</span>tailwind <span class="nt">-a</span><span class="o">=</span>propshaft
rails g scaffold User username
rails g scaffold message body:text
rails g scaffold mention user:references message:references
rails g stimulus mentions
bin/importmap pin tributejs
bundle add requestjs-rails
bin/rails requestjs:install
bundle add faker
</code></pre></div></div>

<p>Add some users to the database:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/seeds.rb</span>
<span class="c1"># User.create username: Faker::Internet.username</span>
<span class="n">usernames</span> <span class="o">=</span> <span class="sx">%w[yshmarov marcoroth adrianthedev lucianghinda robzolkos dhh matz]</span>
<span class="n">usernames</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">username</span><span class="o">|</span>
  <span class="no">User</span><span class="p">.</span><span class="nf">create</span> <span class="ss">username: </span><span class="n">username</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Form field with mentions enabled:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/views/messages/form.html.erb</span>
<span class="o">&lt;</span><span class="sx">%= form.text_area :body, required: true, style: 'width: 100%', rows: 3, data: { controller: 'mentions', mentions_target: 'input' } %&gt;
</span></code></pre></div></div>

<p>Find user by username and return json:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/users_controller.rb</span>
<span class="k">class</span> <span class="nc">UsersController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">index</span>
    <span class="vi">@users</span> <span class="o">=</span> <span class="k">if</span> <span class="n">params</span><span class="p">[</span><span class="ss">:query</span><span class="p">].</span><span class="nf">present?</span>
               <span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="s1">'username ILIKE ?'</span><span class="p">,</span> <span class="s2">"%</span><span class="si">#{</span><span class="n">params</span><span class="p">[</span><span class="ss">:query</span><span class="p">]</span><span class="si">}</span><span class="s2">%"</span><span class="p">)</span>
             <span class="k">else</span>
               <span class="no">User</span><span class="p">.</span><span class="nf">none</span>
             <span class="k">end</span>

    <span class="n">respond_to</span> <span class="k">do</span> <span class="o">|</span><span class="nb">format</span><span class="o">|</span>
      <span class="nb">format</span><span class="p">.</span><span class="nf">json</span> <span class="p">{</span> <span class="n">render</span> <span class="ss">json: </span><span class="vi">@users</span> <span class="p">}</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Stimulus controller to enable TributeJS and search for users:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/controllers/mentions_controller.js</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Controller</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@hotwired/stimulus</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">Tribute</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">tributejs</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="kd">get</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@rails/request.js</span><span class="dl">"</span><span class="p">;</span>

<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">Controller</span> <span class="p">{</span>
  <span class="kd">static</span> <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="dl">"</span><span class="s2">input</span><span class="dl">"</span><span class="p">]</span>

  <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">tribute</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Tribute</span><span class="p">({</span>
      <span class="na">values</span><span class="p">:</span> <span class="k">async </span><span class="p">(</span><span class="nx">text</span><span class="p">,</span> <span class="nx">cb</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
          <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">get</span><span class="p">(</span><span class="s2">`/users.json?query=</span><span class="p">${</span><span class="nx">text</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
          <span class="k">if </span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="p">{</span>
            <span class="kd">const</span> <span class="nx">users</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nx">json</span><span class="p">;</span>
            <span class="nf">cb</span><span class="p">(</span><span class="nx">users</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="nx">user</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="na">key</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">username</span><span class="p">,</span> <span class="na">value</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">username</span> <span class="p">})));</span>
          <span class="p">}</span>
      <span class="p">},</span>
      <span class="na">selectTemplate</span><span class="p">:</span> <span class="nf">function </span><span class="p">(</span><span class="nx">item</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s2">`@</span><span class="p">${</span><span class="nx">item</span><span class="p">.</span><span class="nx">original</span><span class="p">.</span><span class="nx">value</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
      <span class="p">},</span>
    <span class="p">});</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">tribute</span><span class="p">.</span><span class="nf">attach</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">inputTarget</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="nf">disconnect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">tribute</span><span class="p">.</span><span class="nf">detach</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">inputTarget</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><a href="https://github.com/zurb/tribute/blob/master/dist/tribute.css">Import TributeJS CSS</a></p>

<p>After a message is created, parse mentioned usernames and create mentions</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/message.rb</span>
  <span class="n">has_many</span> <span class="ss">:mentions</span>

  <span class="n">after_create_commit</span> <span class="k">do</span>
    <span class="n">extract_mentions</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">extract_mentions</span>
    <span class="n">mentioned_usernames</span> <span class="o">=</span> <span class="n">content</span><span class="p">.</span><span class="nf">scan</span><span class="p">(</span><span class="sr">/@(\w+)/</span><span class="p">).</span><span class="nf">flatten</span>
    <span class="n">mentioned_users</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">username: </span><span class="n">mentioned_usernames</span><span class="p">)</span>
    <span class="n">mentioned_users</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">mentioned_user</span><span class="o">|</span>
      <span class="n">mentions</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="ss">user: </span><span class="n">mentioned_user</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The second regex is actually better:</p>

<p><code class="language-plaintext highlighter-rouge">(/@(\w+)/)</code> =&gt; <code class="language-plaintext highlighter-rouge">@foobar</code>.com</p>

<p><code class="language-plaintext highlighter-rouge">(/@([\w._]+)/)</code> =&gt; <code class="language-plaintext highlighter-rouge">@foobar.com</code></p>

<p>Display mentions in a text:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">MessagesHelper</span>
  <span class="k">def</span> <span class="nf">postprocess</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
    <span class="n">text</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="sr">/@([\w._]+)/</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">mention</span><span class="o">|</span>
      <span class="n">username</span> <span class="o">=</span> <span class="n">mention</span><span class="p">[</span><span class="mi">1</span><span class="o">..-</span><span class="mi">1</span><span class="p">]</span>
      <span class="n">link_to</span> <span class="n">mention</span><span class="p">,</span> <span class="s2">"/users/</span><span class="si">#{</span><span class="n">username</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"text-blue-500"</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="sx">%= simple_format postprocess(message.body) %&gt;
</span></code></pre></div></div>

<p>Finally, test mention creation:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># test/models/mention_test.rb</span>
<span class="nb">require</span> <span class="s1">'test_helper'</span>

<span class="k">class</span> <span class="nc">MentionTest</span> <span class="o">&lt;</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">TestCase</span>
  <span class="nb">test</span> <span class="s1">'create mention'</span> <span class="k">do</span>
    <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">create</span> <span class="ss">username: </span><span class="s2">"foo"</span>
    <span class="n">message</span> <span class="o">=</span> <span class="no">Message</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span><span class="ss">content: </span><span class="s2">"Hello @</span><span class="si">#{</span><span class="n">user</span><span class="p">.</span><span class="nf">username</span><span class="si">}</span><span class="s2">! How are you?"</span><span class="p">)</span>
    <span class="n">assert_equal</span> <span class="mi">1</span><span class="p">,</span> <span class="n">comment</span><span class="p">.</span><span class="nf">mentions</span><span class="p">.</span><span class="nf">count</span>
    <span class="n">assert_equal</span> <span class="mi">1</span><span class="p">,</span> <span class="n">user</span><span class="p">.</span><span class="nf">notifications</span><span class="p">.</span><span class="nf">count</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>That’s it!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="mentions" /><category term="tributejs" /><category term="stimulusjs" /><summary type="html"><![CDATA[Previously I wrote about parsing #tags and @mentions.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Prevent images from bouncing on load with gem active_storage-blurhash</title><link href="https://blog.superails.com/prevent-bouncing-images" rel="alternate" type="text/html" title="Prevent images from bouncing on load with gem active_storage-blurhash" /><published>2024-08-24T00:00:00+00:00</published><updated>2024-08-24T00:00:00+00:00</updated><id>https://blog.superails.com/prevent-bouncing-images</id><content type="html" xml:base="https://blog.superails.com/prevent-bouncing-images"><![CDATA[<p>I recently installed <a href="https://github.com/avo-hq/active_storage-blurhash">gem active_storage-blurhash</a>. It stopped images from boucing on page load.</p>

<p>Before (content bounces while images load):</p>

<p><img src="/assets/images/image_tag-normal.gif" alt="image_tag-normal" /></p>

<p>After (no bounce):</p>

<p><img src="/assets/images/with-blurhash_image_tag.gif" alt="with-blurhash_image_tag" /></p>

<p>🎁 Thanks <a href="https://avohq.io/">Avo</a> for the wonderful gem!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="blurhash" /><category term="active-storage" /><summary type="html"><![CDATA[I recently installed gem active_storage-blurhash. It stopped images from boucing on page load.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">StimulusJS social SHARE button</title><link href="https://blog.superails.com/stimulus-share-button" rel="alternate" type="text/html" title="StimulusJS social SHARE button" /><published>2024-08-21T00:00:00+00:00</published><updated>2024-08-21T00:00:00+00:00</updated><id>https://blog.superails.com/stimulus-share-button</id><content type="html" xml:base="https://blog.superails.com/stimulus-share-button"><![CDATA[<p>If you are building a PWA, you might want to still allow users toto share an URL to current page, so that another user can open it in a browser.</p>

<p>You could add a <a href="/stimulus-copy-to-clipboard">copy URL to clipboard</a> button, but a better approach would be to use the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share">Browser Navigator API</a>.</p>

<p>Here’s how it looks on desktop:</p>

<p><img src="/assets/images/social-share-desktop.png" alt="Navigator API social-share-desktop" /></p>

<p>Mobile:</p>

<p><img src="/assets/images/social-share-mobile.jpeg" alt="Navigator API social-share-mobile" /></p>

<p>Let’s build the share button!</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails g stimulus social-share
</code></pre></div></div>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/controllers/social_share_controller.js</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Controller</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@hotwired/stimulus</span><span class="dl">"</span>
<span class="c1">// &lt;!-- Social Share Button --&gt;</span>
<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nc">extends</span> <span class="nx">Controller</span> <span class="p">{</span>
  <span class="kd">static</span> <span class="nx">values</span> <span class="o">=</span> <span class="p">{</span> <span class="na">url</span><span class="p">:</span> <span class="nb">String</span> <span class="p">}</span>

  <span class="c1">// hide the share button if it's not supported by a browser</span>
  <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nb">navigator</span><span class="p">.</span><span class="nx">share</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">element</span><span class="p">.</span><span class="nx">hidden</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nf">share</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// prevent form submit &amp; redirect</span>
    <span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">();</span>
    <span class="c1">// share!</span>
    <span class="nb">navigator</span><span class="p">.</span><span class="nf">share</span><span class="p">({</span><span class="na">url</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">urlValue</span><span class="p">});</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Finally, add this button to any (or all) URLs in your app:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">button_to</span> <span class="s1">'Share'</span><span class="p">,</span> <span class="s1">'#'</span><span class="p">,</span> <span class="ss">data: </span><span class="p">{</span> <span class="ss">controller: </span><span class="s1">'social-share'</span><span class="p">,</span> <span class="ss">social_share_url_value: </span><span class="n">request</span><span class="p">.</span><span class="nf">url</span><span class="p">,</span> <span class="ss">action: </span><span class="s1">'click-&gt;social-share#share'</span><span class="p">}</span>
</code></pre></div></div>

<p>That’s it!</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="stimulusjs" /><category term="social-share" /><summary type="html"><![CDATA[If you are building a PWA, you might want to still allow users toto share an URL to current page, so that another user can open it in a browser.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Rails 7.2 native rate limiting</title><link href="https://blog.superails.com/rails-rate-limiting" rel="alternate" type="text/html" title="Rails 7.2 native rate limiting" /><published>2024-08-10T00:00:00+00:00</published><updated>2024-08-10T00:00:00+00:00</updated><id>https://blog.superails.com/rails-rate-limiting</id><content type="html" xml:base="https://blog.superails.com/rails-rate-limiting"><![CDATA[<p>Rate limiting sign up &amp; sign in pages is important for securing your app from password-guessing attacks.</p>

<p>You can also add rate limitng to pages users are likely to abuse, like</p>

<p>Previously I wrote about rate limiting with <a href="/gem-rack-attack-devise-rails7">Use Gem Rack-attack with Devise and Rails 7</a>.</p>

<p>Recently rate limiting <a href="https://github.com/rails/rails/commit/179b979ddbb7bcc4d1a12d0d71779f47c1c9d9cd">was added</a> to Rails by default.</p>

<p>You can see the latest docs <a href="https://github.com/rails/rails/blob/main/actionpack/lib/action_controller/metal/rate_limiting.rb">here</a></p>

<h3 id="rate-limiting-devise-registrations--signups">Rate limiting devise registrations &amp; signups</h3>

<p>Add devise and import registrations &amp; sessions controllers so that you can override them.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle add devise
rails g devise:install
rails g devise User
rails db:migrate
rails generate devise:controllers <span class="nb">users</span> <span class="nt">-c</span><span class="o">=</span>registrations sessions
</code></pre></div></div>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code># config/routes.rb
<span class="p">Rails.application.routes.draw do
</span><span class="gd">-  devise_for :users
</span><span class="gi">+  devise_for :users, controllers: { registrations: "users/registrations", sessions: "users/sessions" }
</span><span class="p">end
</span></code></pre></div></div>

<p>Add the <code class="language-plaintext highlighter-rouge">rate_limit</code> before_action. Rails suggests <a href="https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/authentication/templates/controllers/sessions_controller.rb#L3">this default rate_limit setting</a>.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/users/sessions_controller.rb</span>
<span class="k">class</span> <span class="nc">Users::SessionsController</span> <span class="o">&lt;</span> <span class="no">Devise</span><span class="o">::</span><span class="no">SessionsController</span>
  <span class="c1"># default</span>
  <span class="c1"># rate_limit to: 10, within: 3.minutes, by: -&gt; { request.remote_ip }, with: -&gt; { head :too_many_requests }</span>
  <span class="c1"># our approach</span>
  <span class="n">rate_limit</span> <span class="ss">to: </span><span class="mi">10</span><span class="p">,</span> <span class="ss">within: </span><span class="mi">3</span><span class="p">.</span><span class="nf">minutes</span><span class="p">,</span> <span class="ss">only: :create</span><span class="p">,</span> <span class="ss">with: </span><span class="o">-&gt;</span> <span class="p">{</span> <span class="n">redirect_to</span> <span class="n">new_user_session_url</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Try again later."</span> <span class="p">}</span>
  <span class="c1"># test</span>
  <span class="c1"># rate_limit to: 2, within: 1.minute, only: :new</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/users/registrations_controller.rb</span>
<span class="k">class</span> <span class="nc">Users::RegistrationsController</span> <span class="o">&lt;</span> <span class="no">Devise</span><span class="o">::</span><span class="no">RegistrationsController</span>
  <span class="n">rate_limit</span> <span class="ss">to: </span><span class="mi">10</span><span class="p">,</span> <span class="ss">within: </span><span class="mi">3</span><span class="p">.</span><span class="nf">minutes</span><span class="p">,</span> <span class="ss">only: :create</span><span class="p">,</span> <span class="ss">with: </span><span class="o">-&gt;</span> <span class="p">{</span> <span class="n">redirect_to</span> <span class="n">new_user_registration_url</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Try again later."</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Enable rate limiting in dev mode with the <code class="language-plaintext highlighter-rouge">rails dev:cache</code> command.</strong></p>

<p>Now, when one user submits the sign_in or sign_up form &gt;10 times within 3 minutes, he will get a blank page with a <code class="language-plaintext highlighter-rouge">429 Too Many Requests</code> error:</p>

<p><img src="/assets/images/rate-limit-many-requests.png" alt="rate-limit-many-requests" /></p>

<p>Or, as in this example, redirected with an alert:</p>

<p><img src="/assets/images/rate-limit-redirect.png" alt="rate-limit-redirect" /></p>

<p>That’s it! So simple.</p>]]></content><author><name>Yaroslav Shmarov</name></author><category term="rack-attack" /><category term="rate-limiting" /><summary type="html"><![CDATA[Rate limiting sign up &amp; sign in pages is important for securing your app from password-guessing attacks.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" /><media:content medium="image" url="https://blog.superails.com/assets/static-pages/yaro-cindy.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>