Module: RubyNative::Helper

Defined in:
lib/ruby_native/helper.rb

Defined Under Namespace

Classes: NavbarBuilder, NavbarMenuBuilder

Constant Summary collapse

ACTIONS =

No :root. Landing a page as the tab root — dropping what is behind it — is a distinct operation from replacing the current entry, and no shell implements it: every one of them mapped :root onto replace, which unwinds nothing. Offering the word without the behavior is worse than not offering it, so it is out until a shell can honor it.

%w[push replace].freeze
PRESENTATION_INTENTS =

The landing intents a page can declare about itself. Only :root so far. :modal is the obvious second member (both shells hardcode a /new + /edit rule today that no app can reach), which is why this is a vocabulary rather than a boolean tag.

%w[root].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.resolve_icon(icon: nil, icons: nil, platform: nil) ⇒ Object

Picks the right icon name for the current native platform. Accepts the single icon: form (applied to every platform) and/or the icons: hash form ({ ios: "...", android: "..." }). When both are given, a matching icons[platform] wins; otherwise falls back to icon. Returns nil when nothing resolves.

A non-Hash icons: raises rather than falling through. icons: [ios: "...", android: "..."] is an easy slip in ERB and yields an Array holding one Hash, which used to skip the lookup and fall back to icon — usually nil, so the button rendered with no icon on either platform and nothing said why. That reads as "per-platform icons are broken" rather than "wrong bracket," and it is invisible on whichever platform you aren't testing.



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/ruby_native/helper.rb', line 236

def self.resolve_icon(icon: nil, icons: nil, platform: nil)
  if !icons.nil? && !icons.is_a?(Hash)
    raise ArgumentError,
      "icons: must be a Hash like { ios: \"square.and.arrow.up\", android: \"share\" }, " \
      "got #{icons.class}. Check for square brackets instead of curly braces."
  end

  if icons.is_a?(Hash) && platform
    key = platform.to_sym
    per_platform = icons[key] || icons[key.to_s]
    return per_platform if per_platform
  end

  # Nothing matched, which is every web render: `native_platform` is nil in
  # a browser. Fall back to `icon:`, then to any name in `icons:`, the same
  # order `RubyNative.backfill_tab_icons` uses for the YAML config.
  #
  # Returning nil here instead would make `icons:` alone unusable, because
  # `native_fab_tag` raises on a nil icon and would 500 a page that renders
  # fine inside the app. The signal element is hidden and only read by the
  # native app, so which name survives on the web doesn't matter.
  icon || fallback_icon(icons)
end

.validate_action(value, label: "action") ⇒ Object

Validates a push/replace landing value. Returns the value as a string, raises otherwise.



109
110
111
112
113
114
115
116
# File 'lib/ruby_native/helper.rb', line 109

def self.validate_action(value, label: "action")
  value = value.to_s
  unless ACTIONS.include?(value)
    raise ArgumentError,
      "#{label} must be :push or :replace, got #{value.inspect}"
  end
  value
end

Instance Method Details

#native_back_button_tag(text = nil, **options) ⇒ Object



41
42
43
44
45
46
47
48
# File 'lib/ruby_native/helper.rb', line 41

def native_back_button_tag(text = nil, **options)
  options[:class] = [options[:class], "native-back-button"].compact.join(" ")
  default_content = tag.svg(
    tag.path(d: "M15.75 19.5L8.25 12l7.5-7.5", stroke_linecap: "round", stroke_linejoin: "round"),
    width: 24, height: 24, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: 2.5
  )
  tag.button(text || default_content, onclick: "RubyNative.postMessage({action: 'back'})", **options)
end

#native_badge_tag(count = nil, home: nil, tab: nil) ⇒ Object



89
90
91
92
93
94
95
96
97
98
# File 'lib/ruby_native/helper.rb', line 89

def native_badge_tag(count = nil, home: nil, tab: nil)
  home = count if count && home.nil?
  tab = count if count && tab.nil?

  data = { native_badge: "" }
  data[:native_badge_home] = home unless home.nil?
  data[:native_badge_tab] = tab unless tab.nil?

  tag.div(data: data, hidden: true)
end

#native_fab_tag(icon: nil, icons: nil, href: nil, click: nil) ⇒ Object

Raises:

  • (ArgumentError)


192
193
194
195
196
197
198
199
# File 'lib/ruby_native/helper.rb', line 192

def native_fab_tag(icon: nil, icons: nil, href: nil, click: nil)
  resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: try(:native_platform))
  raise ArgumentError, "native_fab_tag requires an icon" if resolved.nil?
  data = { native_fab: true, native_icon: resolved }
  data[:native_href] = href if href
  data[:native_click] = click if click
  tag.div(data: data, hidden: true)
end

#native_form_tagObject



21
22
23
# File 'lib/ruby_native/helper.rb', line 21

def native_form_tag
  tag.div(data: { native_form: true }, hidden: true)
end

#native_haptic_data(feedback = :success, **data) ⇒ Object



205
206
207
208
209
210
# File 'lib/ruby_native/helper.rb', line 205

def native_haptic_data(feedback = :success, **data)
  feedback = feedback.to_s
  feedback = "success" if feedback.empty?
  data[:native_haptic] = feedback
  data
end

#native_identity_tag(value) ⇒ Object



29
30
31
# File 'lib/ruby_native/helper.rb', line 29

def native_identity_tag(value)
  tag.div(data: { native_identity: native_identity_token(value) }, hidden: true)
end

#native_identity_token(value) ⇒ Object

HMAC, not a bare digest: a digest of a small integer id space is enumerable, so the DOM would effectively still carry the id it was meant to hide.



35
36
37
38
39
# File 'lib/ruby_native/helper.rb', line 35

def native_identity_token(value)
  parts = Array(value).compact
  return "" if parts.empty?
  OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secret_key_base, parts.join(":"))[0, 16]
end

#native_navbar_tag(title = nil, pull_to_refresh: true, &block) ⇒ Object



183
184
185
186
187
188
189
190
# File 'lib/ruby_native/helper.rb', line 183

def native_navbar_tag(title = nil, pull_to_refresh: true, &block)
  builder = NavbarBuilder.new(self)
  capture(builder, &block) if block

  data = { native_navbar: title.to_s }
  data[:native_pull_to_refresh] = "false" unless pull_to_refresh
  tag.div(data: data, hidden: true) { builder.to_html }
end

#native_overscroll_tag(top:, bottom: nil) ⇒ Object



201
202
203
# File 'lib/ruby_native/helper.rb', line 201

def native_overscroll_tag(top:, bottom: nil)
  tag.div(data: { native_overscroll_top: top, native_overscroll_bottom: bottom || top }, hidden: true)
end

#native_presentation_tag(presentation) ⇒ Object

Declares that this page is a root: it lands with nothing behind it, and no back affordance, wherever it lands.

<%= native_presentation_tag :root %>

Emits the fact on two channels, because the shells need it at two different moments and neither channel reaches both:

  1. A data-native-presentation element, reported with every other signal once the page has rendered. This is what Normal Mode reads, and Normal Mode can act on it late: it has no push stack, so suppressing back is not a navigation and nothing refetches.

  2. A Native-Presentation response header. Advanced Mode has to decide before the navigation commits, or it pushes and then visibly corrects itself. On a form submission Turbo has already fetched the destination by the time it proposes the visit, so the header is readable at turbo:before-fetch-response — before the proposal — and the header is the only part of that response readable synchronously, since the body arrives as a promise that consuming would take from Turbo.

Nothing is declared at the origin. Both channels ride the response for the page itself, so a redirect chain carries the intent to wherever it actually lands rather than to wherever the link pointed.

In Advanced Mode this takes effect before the navigation commits when the page arrives from a form submission, because that is the only case where Turbo has already fetched the destination by the time it proposes the visit. A link tap, a deep link and a cold boot are proposed before anything is fetched, so those fall back to the element and the shell corrects after the page renders. Normal Mode always uses the element.

Do not call this inside a cache block. On a cache hit the element comes back from the cache and the header is never set, which silently leaves Advanced Mode with only the slower path.



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/ruby_native/helper.rb', line 159

def native_presentation_tag(presentation)
  value = presentation.to_s
  unless PRESENTATION_INTENTS.include?(value)
    raise ArgumentError,
      "native_presentation_tag must be #{PRESENTATION_INTENTS.map { |i| ":#{i}" }.join(" or ")}, " \
      "got #{value.inspect}"
  end

  # `respond_to?` rather than a nil check alone: ActionView forwards it to
  # the controller for the delegated methods, so a view rendered without one
  # answers false here instead of raising a DelegationError.
  if respond_to?(:response) && response
    if Rails.env.development? && response.committed?
      Rails.logger.warn(
        "[ruby_native] native_presentation_tag rendered after the response was committed, " \
        "so Advanced Mode will not see it before the navigation commits."
      )
    end
    response.headers["Native-Presentation"] = value
  end

  tag.div(data: { native_presentation: value }, hidden: true)
end

#native_push_tagObject



25
26
27
# File 'lib/ruby_native/helper.rb', line 25

def native_push_tag
  tag.div(data: { native_push: true }, hidden: true)
end

#native_review_tagObject

Renders a signal element that asks the app to request an App Store rating from the user. The system decides whether to actually show the prompt (Apple throttles it to a few times per year), so it is safe to render this on any page where a review would be welcome, like a confirmation screen after the user finishes something worthwhile.

See Apple's docs on requesting App Store reviews: https://developer.apple.com/documentation/storekit/requesting-app-store-reviews



220
221
222
# File 'lib/ruby_native/helper.rb', line 220

def native_review_tag
  tag.div(data: { native_review: true }, hidden: true)
end

#native_scan_button_tag(label = "Scan", target: nil, event: nil, submit: false, formats: nil, **options) ⇒ Object

Renders a button that opens the native barcode scanner. On a successful scan the value fills target (a CSS selector) and the page receives a ruby-native:scan CustomEvent (override the name with event:). Set submit: true to submit the filled field's form after scanning. Narrow the accepted codes with formats: (neutral names, e.g. "ean13,upce"); omit it for a sane default. Renders a plain button on the web (no-op until opened in the app); wrap in native_app? if it should be hidden there.

<%= native_scan_button_tag "Scan", target: "#isbn" %>

The scanner needs a camera usage description set in your Ruby Native app settings, or scanning is unavailable in production builds.



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/ruby_native/helper.rb', line 62

def native_scan_button_tag(label = "Scan", target: nil, event: nil, submit: false, formats: nil, **options)
  if Rails.env.development?
    Rails.logger.warn(
      "[ruby_native] native_scan_button_tag needs a camera usage description set in your " \
      "Ruby Native app settings, or scanning is unavailable in production builds."
    )
  end

  scan_options = {}
  scan_options[:target] = target if target
  scan_options[:event] = event if event
  scan_options[:submit] = true if submit
  if formats
    scan_options[:formats] = Array(formats).flat_map { |f| f.to_s.split(",") }.map(&:strip).reject(&:empty?)
  end

  # A bare <button> is type="submit" inside a form, and the documented usage
  # puts this inside form_with. Without this a tap would submit the form
  # immediately and navigate away before the async scan result arrives, and
  # the `submit:` option (which is what opts into submitting) would be
  # meaningless. Callers who really want a submit button can pass
  # type: "submit" and keep it.
  options[:type] ||= "button"
  options[:onclick] = "window.RubyNative?.scan(#{scan_options.to_json})"
  tag.button(label, **options)
end

#native_tabs_tag(enabled: true) ⇒ Object



16
17
18
19
# File 'lib/ruby_native/helper.rb', line 16

def native_tabs_tag(enabled: true)
  return "".html_safe unless enabled
  tag.div(data: { native_tabs: true }, hidden: true)
end

#ruby_native_screenshot_session?Boolean

True when the current request is part of a Ruby Native screenshot run. Use this to render deterministically: freeze relative timestamps, hide push banners, suppress ads, disable A/B variants, skip notifications.

<% if ruby_native_screenshot_session? %>
Stamped 2 days ago
<% else %>
<%= time_ago_in_words(stamp.created_at) %>
<% end %>

Returns:

  • (Boolean)


12
13
14
# File 'lib/ruby_native/helper.rb', line 12

def ruby_native_screenshot_session?
  cookies[:_ruby_native_screenshot_session] == "1"
end