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
-
.resolve_icon(icon: nil, icons: nil, platform: nil) ⇒ Object
Picks the right icon name for the current native platform.
-
.validate_action(value, label: "action") ⇒ Object
Validates a push/replace landing value.
Instance Method Summary collapse
- #native_back_button_tag(text = nil, **options) ⇒ Object
- #native_badge_tag(count = nil, home: nil, tab: nil) ⇒ Object
- #native_fab_tag(icon: nil, icons: nil, href: nil, click: nil) ⇒ Object
- #native_form_tag ⇒ Object
- #native_haptic_data(feedback = :success, **data) ⇒ Object
- #native_identity_tag(value) ⇒ Object
-
#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.
- #native_navbar_tag(title = nil, pull_to_refresh: true, &block) ⇒ Object
- #native_overscroll_tag(top:, bottom: nil) ⇒ Object
-
#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_push_tag ⇒ Object
-
#native_review_tag ⇒ Object
Renders a signal element that asks the app to request an App Store rating from the user.
-
#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.
- #native_tabs_tag(enabled: true) ⇒ Object
-
#ruby_native_screenshot_session? ⇒ Boolean
True when the current request is part of a Ruby Native screenshot run.
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 (text = nil, **) [:class] = [[: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.(text || default_content, onclick: "RubyNative.postMessage({action: 'back'})", **) 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
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_tag ⇒ Object
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 (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:
-
A
data-native-presentationelement, 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. -
A
Native-Presentationresponse 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 atturbo: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_tag ⇒ Object
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_tag ⇒ Object
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 (label = "Scan", target: nil, event: nil, submit: false, formats: nil, **) 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 = {} [:target] = target if target [:event] = event if event [:submit] = true if submit if formats [: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. [:type] ||= "button" [:onclick] = "window.RubyNative?.scan(#{.to_json})" tag.(label, **) 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 %>
12 13 14 |
# File 'lib/ruby_native/helper.rb', line 12 def ruby_native_screenshot_session? [:_ruby_native_screenshot_session] == "1" end |