ruby_everywhere 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +255 -0
- data/bridge/README.md +48 -0
- data/bridge/everywhere/bridge.js +388 -3
- data/bridge/everywhere/native.css +203 -0
- data/bridge/package.json +6 -3
- data/lib/everywhere/android_resources.rb +217 -0
- data/lib/everywhere/android_sdk.rb +330 -0
- data/lib/everywhere/asset_catalog.rb +185 -0
- data/lib/everywhere/auth_handoff.rb +398 -0
- data/lib/everywhere/auth_token.rb +118 -0
- data/lib/everywhere/builders/android.rb +844 -0
- data/lib/everywhere/builders/ios.rb +72 -3
- data/lib/everywhere/commands/build.rb +51 -9
- data/lib/everywhere/commands/clean.rb +10 -5
- data/lib/everywhere/commands/dev.rb +46 -8
- data/lib/everywhere/commands/doctor.rb +89 -3
- data/lib/everywhere/commands/install.rb +1 -1
- data/lib/everywhere/commands/logs.rb +117 -8
- data/lib/everywhere/config.rb +513 -14
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +21 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/log_filter.rb +74 -0
- data/lib/everywhere/mobile_config_endpoint.rb +13 -0
- data/lib/everywhere/mobile_configs_controller.rb +18 -0
- data/lib/everywhere/native_helper.rb +198 -5
- data/lib/everywhere/omniauth.rb +88 -0
- data/lib/everywhere/paths.rb +47 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/README.md +217 -0
- data/support/mobile/android/app/build.gradle.kts +156 -0
- data/support/mobile/android/app/everywhere.properties +8 -0
- data/support/mobile/android/app/native-packages.gradle.kts +16 -0
- data/support/mobile/android/app/proguard-rules.pro +21 -0
- data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
- data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
- data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
- data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
- data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
- data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
- data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
- data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
- data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
- data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
- data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
- data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
- data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
- data/support/mobile/android/build.gradle.kts +8 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
- data/support/mobile/android/gradle.properties +14 -0
- data/support/mobile/android/gradlew +251 -0
- data/support/mobile/android/gradlew.bat +94 -0
- data/support/mobile/android/settings.gradle.kts +28 -0
- data/support/mobile/ios/App/AppDelegate.swift +7 -1
- data/support/mobile/ios/App/AuthFlow.swift +101 -0
- data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +105 -0
- data/support/mobile/ios/App/SceneDelegate.swift +144 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +8 -0
- data/support/mobile/ios/README.md +10 -0
- metadata +69 -1
data/bridge/everywhere/bridge.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// Everywhere.on("menu", handler) // shell events; returns unsubscribe fn
|
|
17
17
|
// Everywhere.visit("/settings") // Turbo.visit with location fallback
|
|
18
18
|
// Everywhere.reloadTabs() // re-fetch native tab/nav config (mobile)
|
|
19
|
+
// Everywhere.auth.signIn("/auth/github") // provider sign-in in the system browser (mobile)
|
|
19
20
|
//
|
|
20
21
|
// Everywhere.haptics.impact("medium") // light | medium | heavy | soft | rigid
|
|
21
22
|
// Everywhere.haptics.notification("success") // success | warning | error
|
|
@@ -103,6 +104,50 @@
|
|
|
103
104
|
// notes is the markdown source; notesHtml is the same notes pre-rendered to
|
|
104
105
|
// HTML (from the signed update feed — your own content), ready for a
|
|
105
106
|
// changelog modal: el.innerHTML = notesHtml.
|
|
107
|
+
//
|
|
108
|
+
// Everywhere.menu({ title, message, items }) // Promise<item|null>
|
|
109
|
+
// items: [{ title, id?, style?, disabled? }] style: "destructive" | "cancel"
|
|
110
|
+
// Native action sheet in the mobile shell; a styled bottom sheet everywhere
|
|
111
|
+
// else. Resolves the chosen item (null on cancel/dismiss).
|
|
112
|
+
//
|
|
113
|
+
// Native chrome, declarative (no JS) — the bridge watches every page and,
|
|
114
|
+
// inside the mobile shell, lifts these into real native controls; in a
|
|
115
|
+
// browser (and the desktop shell) they stay as the plain HTML they already
|
|
116
|
+
// are, so the same markup works everywhere. Tapping a native control just
|
|
117
|
+
// .click()s the element it mirrors — a link navigates, a submit submits, a
|
|
118
|
+
// button fires its handler — so behavior is defined once, in the DOM.
|
|
119
|
+
//
|
|
120
|
+
// Nav bar button (right side by default):
|
|
121
|
+
// <a href="/notes/new" data-everywhere-nav-button
|
|
122
|
+
// data-everywhere-nav-title="New" data-everywhere-nav-icon="plus">New</a>
|
|
123
|
+
// Nav bar submit button (submits the form when tapped):
|
|
124
|
+
// <button type="submit" data-everywhere-nav-button
|
|
125
|
+
// data-everywhere-nav-title="Save" data-everywhere-nav-style="done">Save</button>
|
|
126
|
+
// Nav bar pull-down / overflow menu (defaults to the ⋯ ellipsis icon):
|
|
127
|
+
// <div data-everywhere-nav-menu data-everywhere-nav-title="More">
|
|
128
|
+
// <a href="/share" data-everywhere-menu-item data-everywhere-menu-title="Share"
|
|
129
|
+
// data-everywhere-menu-icon-ios="square.and.arrow.up"
|
|
130
|
+
// data-everywhere-menu-icon-android="share">Share</a>
|
|
131
|
+
// <button form="delete_1" data-everywhere-menu-item data-everywhere-menu-title="Delete"
|
|
132
|
+
// data-everywhere-menu-style="destructive">Delete</button>
|
|
133
|
+
// </div>
|
|
134
|
+
// In-content action sheet (a trigger + the items it opens):
|
|
135
|
+
// <div data-everywhere-menu>
|
|
136
|
+
// <button data-everywhere-menu-trigger>Options</button>
|
|
137
|
+
// <div data-everywhere-menu-items>
|
|
138
|
+
// <a href="/x/edit" data-everywhere-menu-item>Edit</a>
|
|
139
|
+
// <button form="del" data-everywhere-menu-item
|
|
140
|
+
// data-everywhere-menu-style="destructive">Delete</button>
|
|
141
|
+
// </div>
|
|
142
|
+
// </div>
|
|
143
|
+
// Icons are per-platform, like tabs: -icon-ios is an SF Symbol, -icon-android
|
|
144
|
+
// a Material name, and a plain -icon is the shared fallback; the bridge picks
|
|
145
|
+
// by the running os, so one page serves both shells. Icons are ignored on the
|
|
146
|
+
// web. side is "left" or "right" (default). Nav-bar sources are hidden in the
|
|
147
|
+
// shell (they live in the bar now) but shown in a browser; the action-sheet
|
|
148
|
+
// trigger toggles its items as an inline menu when there's no native shell.
|
|
149
|
+
// These are the everywhere_nav_button / everywhere_nav_menu /
|
|
150
|
+
// everywhere_submit_button / everywhere_menu Rails helpers.
|
|
106
151
|
|
|
107
152
|
const config =
|
|
108
153
|
(typeof window !== "undefined" && window.__EVERYWHERE_CONFIG__) || {}
|
|
@@ -395,6 +440,7 @@ const HAPTICS_COMPONENT = "everywhere--haptics"
|
|
|
395
440
|
const PERMISSIONS_COMPONENT = "everywhere--permissions"
|
|
396
441
|
const BIOMETRICS_COMPONENT = "everywhere--biometrics"
|
|
397
442
|
const STORAGE_COMPONENT = "everywhere--storage"
|
|
443
|
+
const MENU_COMPONENT = "everywhere--menu"
|
|
398
444
|
|
|
399
445
|
function installWebBridge() {
|
|
400
446
|
if (window.HotwireNative) return
|
|
@@ -487,8 +533,9 @@ const mobile = {
|
|
|
487
533
|
// Ask the shell to re-fetch its native navigation config (tabs + path
|
|
488
534
|
// rules) now, instead of waiting for the next app foreground. Call it after
|
|
489
535
|
// a change that affects which tabs should show — signing in or out — so the
|
|
490
|
-
// tab bar updates immediately. Uses a dedicated
|
|
491
|
-
//
|
|
536
|
+
// tab bar updates immediately. Uses the shell's control channel (a dedicated
|
|
537
|
+
// WKScriptMessageHandler on iOS, a WebMessageListener on Android), so it
|
|
538
|
+
// doesn't depend on a bridge component being mounted on the page.
|
|
492
539
|
reloadTabs() {
|
|
493
540
|
const control = nativeControlChannel()
|
|
494
541
|
if (control) {
|
|
@@ -521,6 +568,16 @@ const mobile = {
|
|
|
521
568
|
if (control) control.postMessage({ action: "clearInstance", ...(to ? { to } : {}) })
|
|
522
569
|
},
|
|
523
570
|
|
|
571
|
+
// Open a provider sign-in in the system browser rather than this web view —
|
|
572
|
+
// where Google refuses to run at all and the rest lose the password manager
|
|
573
|
+
// and the user's existing session. The shell takes it from here: auth
|
|
574
|
+
// session, one-time handoff token, full reset onto the signed-in app.
|
|
575
|
+
authSignIn(path) {
|
|
576
|
+
const control = nativeControlChannel()
|
|
577
|
+
if (control) control.postMessage({ action: "authFlow", to: path })
|
|
578
|
+
else window.location.assign(path)
|
|
579
|
+
},
|
|
580
|
+
|
|
524
581
|
// Real haptics via the shell's "everywhere--haptics" component; vibration
|
|
525
582
|
// fallback while the handshake completes or on an older shell.
|
|
526
583
|
haptic(kind, value) {
|
|
@@ -633,13 +690,67 @@ const mobile = {
|
|
|
633
690
|
}
|
|
634
691
|
})
|
|
635
692
|
})
|
|
693
|
+
},
|
|
694
|
+
|
|
695
|
+
// Native chrome (nav bar items + action sheets) via the shell's
|
|
696
|
+
// "everywhere--menu" component. Unlike the other components these replies are
|
|
697
|
+
// not one-shot: native re-replies to the SAME message on every tap, so the
|
|
698
|
+
// caller owns the callback's lifetime (menuRemove) rather than clearing it on
|
|
699
|
+
// first reply.
|
|
700
|
+
menuSupported() {
|
|
701
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
702
|
+
return !!(web && web.supportsComponent(MENU_COMPONENT))
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
menuSend(event, data, callback) {
|
|
706
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
707
|
+
if (!web || !web.supportsComponent(MENU_COMPONENT)) return null
|
|
708
|
+
return web.send({ component: MENU_COMPONENT, event, data, callback })
|
|
709
|
+
},
|
|
710
|
+
|
|
711
|
+
menuRemove(id) {
|
|
712
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
713
|
+
if (web && id !== null && id !== undefined && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
636
714
|
}
|
|
637
715
|
}
|
|
638
716
|
|
|
717
|
+
// The shell's out-of-band command line: app-level actions (reset, tabs,
|
|
718
|
+
// badges, instance, auth) that must work on any page, whether or not a bridge
|
|
719
|
+
// component happens to be mounted on it. Each shell injects it its own way,
|
|
720
|
+
// and the two APIs disagree about what a message is:
|
|
721
|
+
//
|
|
722
|
+
// iOS — WKUserContentController.add(_:name:) hands us
|
|
723
|
+
// window.webkit.messageHandlers.everywhereControl, whose
|
|
724
|
+
// postMessage takes an *object* and serializes it across the
|
|
725
|
+
// boundary for us.
|
|
726
|
+
// Android — WebViewCompat.addWebMessageListener("everywhereControl") hands us
|
|
727
|
+
// window.everywhereControl, backed by a WebMessagePort, and a web
|
|
728
|
+
// message is *text*: postMessage(String). (Android has no user
|
|
729
|
+
// scripts either, so the shell injects this with
|
|
730
|
+
// addDocumentStartJavaScript — it's in place before page JS runs.)
|
|
731
|
+
//
|
|
732
|
+
// Normalizing here rather than at each call site is the point: the seven
|
|
733
|
+
// callers below all speak the object shape, and only this function knows which
|
|
734
|
+
// shell is listening. iOS gets its handler back untouched — it already is the
|
|
735
|
+
// normalized shape, so that path stays byte-for-byte what it was.
|
|
639
736
|
function nativeControlChannel() {
|
|
640
|
-
|
|
737
|
+
if (typeof window === "undefined") return null
|
|
738
|
+
|
|
739
|
+
const webkit =
|
|
740
|
+
window.webkit &&
|
|
641
741
|
window.webkit.messageHandlers &&
|
|
642
742
|
window.webkit.messageHandlers.everywhereControl
|
|
743
|
+
if (webkit) return webkit
|
|
744
|
+
|
|
745
|
+
const android = window.everywhereControl
|
|
746
|
+
if (android && typeof android.postMessage === "function") {
|
|
747
|
+
// Built per call, not cached: a fresh document (a Turbo drive visit is
|
|
748
|
+
// not, but a real navigation is) gets a fresh injected object, and there's
|
|
749
|
+
// nothing here worth keeping alive across one.
|
|
750
|
+
return { postMessage: (message) => android.postMessage(JSON.stringify(message)) }
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return null
|
|
643
754
|
}
|
|
644
755
|
|
|
645
756
|
// --- public surface ----------------------------------------------------------
|
|
@@ -689,6 +800,26 @@ export const Everywhere = {
|
|
|
689
800
|
else window.location.assign(path)
|
|
690
801
|
},
|
|
691
802
|
|
|
803
|
+
// A native action sheet in the mobile shell; a styled bottom sheet in the
|
|
804
|
+
// browser and desktop shell. Resolves the chosen item (the same object you
|
|
805
|
+
// passed), or null on cancel/dismiss:
|
|
806
|
+
//
|
|
807
|
+
// const choice = await Everywhere.menu({
|
|
808
|
+
// title: "Post",
|
|
809
|
+
// items: [{ id: "share", title: "Share" },
|
|
810
|
+
// { id: "delete", title: "Delete", style: "destructive" }]
|
|
811
|
+
// })
|
|
812
|
+
// if (choice?.id === "delete") …
|
|
813
|
+
menu(options = {}) {
|
|
814
|
+
const items = Array.isArray(options.items) ? options.items : []
|
|
815
|
+
return presentMenu({
|
|
816
|
+
title: options.title,
|
|
817
|
+
message: options.message,
|
|
818
|
+
items,
|
|
819
|
+
source: options.source
|
|
820
|
+
}).then((id) => (id == null ? null : items.find((item) => String(item.id) === String(id)) || null))
|
|
821
|
+
},
|
|
822
|
+
|
|
692
823
|
// Refresh the native tab bar / navigation config now (mobile only; a no-op
|
|
693
824
|
// in the browser and desktop shell, where there's nothing to refresh).
|
|
694
825
|
reloadTabs() {
|
|
@@ -704,6 +835,18 @@ export const Everywhere = {
|
|
|
704
835
|
else this.visit(to || window.location.pathname)
|
|
705
836
|
},
|
|
706
837
|
|
|
838
|
+
// Third-party sign-in (everywhere.yml `auth:`). You rarely need this: the
|
|
839
|
+
// shell already diverts the paths listed there, so a plain link to
|
|
840
|
+
// /auth/github does the right thing everywhere. Call it when the sign-in is
|
|
841
|
+
// driven by something the shell can't see as a visit — a fetch, a custom
|
|
842
|
+
// element, a button that has other work to do first.
|
|
843
|
+
auth: {
|
|
844
|
+
signIn(path) {
|
|
845
|
+
if (adapter.authSignIn) adapter.authSignIn(path)
|
|
846
|
+
else window.location.assign(path)
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
|
|
707
850
|
clipboard: {
|
|
708
851
|
write(text) {
|
|
709
852
|
return adapter.clipboardWrite(text)
|
|
@@ -1032,10 +1175,237 @@ async function wireBiometricToggles() {
|
|
|
1032
1175
|
})
|
|
1033
1176
|
}
|
|
1034
1177
|
|
|
1178
|
+
// --- native chrome (menus, nav bar items) -----------------------------------
|
|
1179
|
+
// Picks the native action sheet in the mobile shell and a DOM bottom sheet
|
|
1180
|
+
// (native.css .everywhere-sheet) everywhere else. Resolves the chosen item's
|
|
1181
|
+
// id, or null on cancel/dismiss.
|
|
1182
|
+
function presentMenu({ title, message, items, source }) {
|
|
1183
|
+
const list = (items || []).map((item, i) => ({
|
|
1184
|
+
id: item.id != null ? String(item.id) : String(i),
|
|
1185
|
+
title: item.title || "",
|
|
1186
|
+
image: item.image || null,
|
|
1187
|
+
style: item.style || "default",
|
|
1188
|
+
disabled: !!item.disabled
|
|
1189
|
+
}))
|
|
1190
|
+
|
|
1191
|
+
if (platform === "mobile" && adapter.menuSupported && adapter.menuSupported()) {
|
|
1192
|
+
return new Promise((resolve) => {
|
|
1193
|
+
let settled = false
|
|
1194
|
+
const id = adapter.menuSend("menu", { title, message, items: list, source }, (message) => {
|
|
1195
|
+
if (settled) return
|
|
1196
|
+
settled = true
|
|
1197
|
+
adapter.menuRemove(id)
|
|
1198
|
+
const data = (message && message.data) || {}
|
|
1199
|
+
resolve(data.action === "select" && data.id != null ? String(data.id) : null)
|
|
1200
|
+
})
|
|
1201
|
+
if (id === null) resolve(domMenu({ title, message, items: list }))
|
|
1202
|
+
})
|
|
1203
|
+
}
|
|
1204
|
+
return domMenu({ title, message, items: list })
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// The honest web/desktop fallback for Everywhere.menu(): a modal bottom sheet
|
|
1208
|
+
// built from the same item list. No inline scripts — pure DOM — so it clears CSP.
|
|
1209
|
+
function domMenu({ title, message, items }) {
|
|
1210
|
+
if (typeof document === "undefined") return Promise.resolve(null)
|
|
1211
|
+
|
|
1212
|
+
return new Promise((resolve) => {
|
|
1213
|
+
const overlay = document.createElement("div")
|
|
1214
|
+
overlay.className = "everywhere-sheet-overlay"
|
|
1215
|
+
const sheet = document.createElement("div")
|
|
1216
|
+
sheet.className = "everywhere-sheet"
|
|
1217
|
+
sheet.setAttribute("role", "menu")
|
|
1218
|
+
|
|
1219
|
+
if (title || message) {
|
|
1220
|
+
const header = document.createElement("div")
|
|
1221
|
+
header.className = "everywhere-sheet-header"
|
|
1222
|
+
if (title) {
|
|
1223
|
+
const el = document.createElement("div")
|
|
1224
|
+
el.className = "everywhere-sheet-title"
|
|
1225
|
+
el.textContent = title
|
|
1226
|
+
header.appendChild(el)
|
|
1227
|
+
}
|
|
1228
|
+
if (message) {
|
|
1229
|
+
const el = document.createElement("div")
|
|
1230
|
+
el.className = "everywhere-sheet-message"
|
|
1231
|
+
el.textContent = message
|
|
1232
|
+
header.appendChild(el)
|
|
1233
|
+
}
|
|
1234
|
+
sheet.appendChild(header)
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
let done = false
|
|
1238
|
+
const close = (value) => {
|
|
1239
|
+
if (done) return
|
|
1240
|
+
done = true
|
|
1241
|
+
document.removeEventListener("keydown", onKey)
|
|
1242
|
+
overlay.remove()
|
|
1243
|
+
resolve(value)
|
|
1244
|
+
}
|
|
1245
|
+
const onKey = (event) => { if (event.key === "Escape") close(null) }
|
|
1246
|
+
|
|
1247
|
+
items.forEach((item) => {
|
|
1248
|
+
const button = document.createElement("button")
|
|
1249
|
+
button.type = "button"
|
|
1250
|
+
button.className = "everywhere-sheet-item" +
|
|
1251
|
+
(item.style === "destructive" ? " everywhere-sheet-item-destructive" : "")
|
|
1252
|
+
button.textContent = item.title
|
|
1253
|
+
button.setAttribute("role", "menuitem")
|
|
1254
|
+
if (item.disabled) button.disabled = true
|
|
1255
|
+
else button.addEventListener("click", () => close(item.id))
|
|
1256
|
+
sheet.appendChild(button)
|
|
1257
|
+
})
|
|
1258
|
+
|
|
1259
|
+
const cancel = document.createElement("button")
|
|
1260
|
+
cancel.type = "button"
|
|
1261
|
+
cancel.className = "everywhere-sheet-item everywhere-sheet-cancel"
|
|
1262
|
+
cancel.textContent = "Cancel"
|
|
1263
|
+
cancel.addEventListener("click", () => close(null))
|
|
1264
|
+
sheet.appendChild(cancel)
|
|
1265
|
+
|
|
1266
|
+
overlay.addEventListener("click", (event) => { if (event.target === overlay) close(null) })
|
|
1267
|
+
document.addEventListener("keydown", onKey)
|
|
1268
|
+
overlay.appendChild(sheet)
|
|
1269
|
+
document.body.appendChild(overlay)
|
|
1270
|
+
})
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Per-platform icon, resolved like tabs: `<base>-<os>` (SF Symbol on iOS, a
|
|
1274
|
+
// Material name on Android) then the shared `<base>`. One page serves both shells.
|
|
1275
|
+
function resolveNativeIcon(el, base) {
|
|
1276
|
+
return el.getAttribute(`${base}-${os}`) || el.getAttribute(base) || null
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function navText(el) {
|
|
1280
|
+
return (el.getAttribute("data-everywhere-nav-title") || el.textContent || "").trim()
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function navSide(el) {
|
|
1284
|
+
return el.getAttribute("data-everywhere-nav-side") === "left" ? "left" : "right"
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function menuItemTitle(el) {
|
|
1288
|
+
return (el.getAttribute("data-everywhere-menu-title") || el.textContent || "").trim()
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// The nav bar items the current page declares, plus a fresh id→element map so
|
|
1292
|
+
// native taps can .click() the source element. Menu children get ids like
|
|
1293
|
+
// "n2.0" so a pull-down item routes back to the right element.
|
|
1294
|
+
let navMessageId = null
|
|
1295
|
+
const navElements = new Map()
|
|
1296
|
+
|
|
1297
|
+
function collectNavItems() {
|
|
1298
|
+
navElements.clear()
|
|
1299
|
+
const items = []
|
|
1300
|
+
let n = 0
|
|
1301
|
+
|
|
1302
|
+
document.querySelectorAll("[data-everywhere-nav-button]").forEach((el) => {
|
|
1303
|
+
const id = "n" + (n++)
|
|
1304
|
+
navElements.set(id, el)
|
|
1305
|
+
items.push({
|
|
1306
|
+
id,
|
|
1307
|
+
kind: "button",
|
|
1308
|
+
title: navText(el),
|
|
1309
|
+
image: resolveNativeIcon(el, "data-everywhere-nav-icon"),
|
|
1310
|
+
side: navSide(el),
|
|
1311
|
+
style: el.getAttribute("data-everywhere-nav-style") || "plain",
|
|
1312
|
+
disabled: el.disabled === true || el.hasAttribute("data-everywhere-nav-disabled")
|
|
1313
|
+
})
|
|
1314
|
+
})
|
|
1315
|
+
|
|
1316
|
+
document.querySelectorAll("[data-everywhere-nav-menu]").forEach((menuEl) => {
|
|
1317
|
+
const id = "n" + (n++)
|
|
1318
|
+
const children = []
|
|
1319
|
+
menuEl.querySelectorAll("[data-everywhere-menu-item]").forEach((child, i) => {
|
|
1320
|
+
const cid = id + "." + i
|
|
1321
|
+
navElements.set(cid, child)
|
|
1322
|
+
children.push({
|
|
1323
|
+
id: cid,
|
|
1324
|
+
title: menuItemTitle(child),
|
|
1325
|
+
image: resolveNativeIcon(child, "data-everywhere-menu-icon"),
|
|
1326
|
+
style: child.getAttribute("data-everywhere-menu-style") || "default"
|
|
1327
|
+
})
|
|
1328
|
+
})
|
|
1329
|
+
items.push({
|
|
1330
|
+
id,
|
|
1331
|
+
kind: "menu",
|
|
1332
|
+
title: navText(menuEl),
|
|
1333
|
+
image: resolveNativeIcon(menuEl, "data-everywhere-nav-icon") || "ellipsis.circle",
|
|
1334
|
+
side: navSide(menuEl),
|
|
1335
|
+
children
|
|
1336
|
+
})
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
return items
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Send this page's nav bar items to the shell. Native re-replies on every tap,
|
|
1343
|
+
// so the callback lives until the next page swaps it out (menuRemove first).
|
|
1344
|
+
function applyNavItems() {
|
|
1345
|
+
if (platform !== "mobile" || !adapter.menuSupported || !adapter.menuSupported()) return
|
|
1346
|
+
if (navMessageId !== null) {
|
|
1347
|
+
adapter.menuRemove(navMessageId)
|
|
1348
|
+
navMessageId = null
|
|
1349
|
+
}
|
|
1350
|
+
const items = collectNavItems()
|
|
1351
|
+
if (!items.length) return
|
|
1352
|
+
|
|
1353
|
+
navMessageId = adapter.menuSend("navItems", { items }, (message) => {
|
|
1354
|
+
const data = (message && message.data) || {}
|
|
1355
|
+
if (data.action === "tap" && data.id != null) {
|
|
1356
|
+
const el = navElements.get(String(data.id))
|
|
1357
|
+
if (el) el.click()
|
|
1358
|
+
}
|
|
1359
|
+
})
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// A tap on an in-content [data-everywhere-menu-trigger]: a native action sheet
|
|
1363
|
+
// in the shell, an inline disclosure (toggling [data-everywhere-menu-open]) in
|
|
1364
|
+
// a browser. Either way, choosing an item .click()s the element it maps to.
|
|
1365
|
+
function handleMenuTrigger(event, trigger) {
|
|
1366
|
+
const container = trigger.closest("[data-everywhere-menu]") || trigger.parentElement
|
|
1367
|
+
if (!container) return
|
|
1368
|
+
const holder = container.querySelector("[data-everywhere-menu-items]") || container
|
|
1369
|
+
const elements = Array.from(holder.querySelectorAll("[data-everywhere-menu-item]"))
|
|
1370
|
+
|
|
1371
|
+
if (platform === "mobile" && adapter.menuSupported && adapter.menuSupported()) {
|
|
1372
|
+
event.preventDefault()
|
|
1373
|
+
const rect = trigger.getBoundingClientRect()
|
|
1374
|
+
const items = elements.map((el, i) => ({
|
|
1375
|
+
id: String(i),
|
|
1376
|
+
title: menuItemTitle(el),
|
|
1377
|
+
image: resolveNativeIcon(el, "data-everywhere-menu-icon"),
|
|
1378
|
+
style: el.getAttribute("data-everywhere-menu-style") || "default",
|
|
1379
|
+
disabled: el.disabled === true
|
|
1380
|
+
}))
|
|
1381
|
+
presentMenu({
|
|
1382
|
+
title: trigger.getAttribute("data-everywhere-menu-title") ||
|
|
1383
|
+
container.getAttribute("data-everywhere-menu-title") || null,
|
|
1384
|
+
items,
|
|
1385
|
+
source: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }
|
|
1386
|
+
}).then((id) => {
|
|
1387
|
+
if (id == null) return
|
|
1388
|
+
const el = elements[parseInt(id, 10)]
|
|
1389
|
+
if (el) el.click()
|
|
1390
|
+
})
|
|
1391
|
+
} else {
|
|
1392
|
+
event.preventDefault()
|
|
1393
|
+
const open = container.hasAttribute("data-everywhere-menu-open")
|
|
1394
|
+
closeInlineMenus()
|
|
1395
|
+
if (!open) container.setAttribute("data-everywhere-menu-open", "")
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function closeInlineMenus() {
|
|
1400
|
+
document.querySelectorAll("[data-everywhere-menu-open]")
|
|
1401
|
+
.forEach((el) => el.removeAttribute("data-everywhere-menu-open"))
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1035
1404
|
function applyDeclarative() {
|
|
1036
1405
|
applyBadgeMetas()
|
|
1037
1406
|
applyBiometricGates()
|
|
1038
1407
|
wireBiometricToggles()
|
|
1408
|
+
applyNavItems()
|
|
1039
1409
|
}
|
|
1040
1410
|
|
|
1041
1411
|
if (typeof document !== "undefined") {
|
|
@@ -1061,6 +1431,13 @@ if (typeof document !== "undefined") {
|
|
|
1061
1431
|
if (content) content.hidden = true
|
|
1062
1432
|
if (overlay) overlay.hidden = true
|
|
1063
1433
|
})
|
|
1434
|
+
// Never snapshot an open inline menu; the next page's applyNavItems()
|
|
1435
|
+
// replaces the nav callback, so drop this page's before it's cached.
|
|
1436
|
+
closeInlineMenus()
|
|
1437
|
+
if (navMessageId !== null) {
|
|
1438
|
+
adapter.menuRemove(navMessageId)
|
|
1439
|
+
navMessageId = null
|
|
1440
|
+
}
|
|
1064
1441
|
})
|
|
1065
1442
|
|
|
1066
1443
|
document.addEventListener("click", (event) => {
|
|
@@ -1071,6 +1448,14 @@ if (typeof document !== "undefined") {
|
|
|
1071
1448
|
const unlock = event.target.closest("[data-everywhere-biometric-unlock]")
|
|
1072
1449
|
const gate = unlock && unlock.closest("[data-everywhere-biometric-lock]")
|
|
1073
1450
|
if (gate) resolveBiometricGate(gate, { prompt: true })
|
|
1451
|
+
|
|
1452
|
+
const trigger = event.target.closest("[data-everywhere-menu-trigger]")
|
|
1453
|
+
if (trigger) {
|
|
1454
|
+
handleMenuTrigger(event, trigger)
|
|
1455
|
+
} else if (!event.target.closest("[data-everywhere-menu-open]")) {
|
|
1456
|
+
// A click anywhere outside an open inline menu dismisses it.
|
|
1457
|
+
closeInlineMenus()
|
|
1458
|
+
}
|
|
1074
1459
|
})
|
|
1075
1460
|
|
|
1076
1461
|
document.addEventListener("change", async (event) => {
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/* @rubyeverywhere/bridge — safe-area utilities for the native shells.
|
|
2
|
+
*
|
|
3
|
+
* Pair with <meta name="viewport" content="…, viewport-fit=cover"> so the
|
|
4
|
+
* browser exposes the device's safe-area insets. These classes pad content
|
|
5
|
+
* clear of the status bar / Dynamic Island / home indicator without
|
|
6
|
+
* hardcoding device sizes. On the desktop shell and plain browsers the insets
|
|
7
|
+
* are 0, so the classes are inert — safe to apply unconditionally.
|
|
8
|
+
*
|
|
9
|
+
* Include in Rails: <%= stylesheet_link_tag "everywhere/native" %>
|
|
10
|
+
* (Sinatra/Hanami: it's vendored to public/ by `every install`.)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
:root {
|
|
14
|
+
--everywhere-inset-top: env(safe-area-inset-top, 0px);
|
|
15
|
+
--everywhere-inset-right: env(safe-area-inset-right, 0px);
|
|
16
|
+
--everywhere-inset-bottom: env(safe-area-inset-bottom, 0px);
|
|
17
|
+
--everywhere-inset-left: env(safe-area-inset-left, 0px);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Padding utilities — add safe-area room on top of any existing padding. */
|
|
21
|
+
.native-inset {
|
|
22
|
+
padding-top: calc(var(--everywhere-inset-top) + var(--native-inset-pad, 0px));
|
|
23
|
+
padding-bottom: calc(var(--everywhere-inset-bottom) + var(--native-inset-pad, 0px));
|
|
24
|
+
}
|
|
25
|
+
.native-inset-top { padding-top: calc(var(--everywhere-inset-top) + var(--native-inset-pad, 0px)); }
|
|
26
|
+
.native-inset-bottom { padding-bottom: calc(var(--everywhere-inset-bottom) + var(--native-inset-pad, 0px)); }
|
|
27
|
+
.native-inset-left { padding-left: calc(var(--everywhere-inset-left) + var(--native-inset-pad, 0px)); }
|
|
28
|
+
.native-inset-right { padding-right: calc(var(--everywhere-inset-right) + var(--native-inset-pad, 0px)); }
|
|
29
|
+
|
|
30
|
+
/* Margin variants, when padding would fight an existing background. */
|
|
31
|
+
.native-inset-mt { margin-top: var(--everywhere-inset-top); }
|
|
32
|
+
.native-inset-mb { margin-bottom: var(--everywhere-inset-bottom); }
|
|
33
|
+
|
|
34
|
+
/* A bar pinned to the bottom edge that stays above the home indicator. */
|
|
35
|
+
.native-safe-bottom {
|
|
36
|
+
padding-bottom: max(var(--everywhere-inset-bottom), var(--native-safe-min, 0.5rem));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Fixed/absolute elements anchored near the bottom (toasts, nudges, FABs):
|
|
40
|
+
* lifts them clear of the home indicator AND anything overlaying the webview
|
|
41
|
+
* from below — on iOS the floating tab bar contributes to the bottom inset.
|
|
42
|
+
* --native-bottom-gap is the visual gap above that (default 1rem). */
|
|
43
|
+
.native-bottom {
|
|
44
|
+
bottom: calc(var(--everywhere-inset-bottom) + var(--native-bottom-gap, 1rem));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Full-height element that respects both vertical insets. */
|
|
48
|
+
.native-safe-height {
|
|
49
|
+
min-height: calc(100dvh - var(--everywhere-inset-top) - var(--everywhere-inset-bottom));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ---------------------------------------------------------------------------
|
|
53
|
+
* Native chrome — styling for the everywhere_fab / everywhere_menu helpers and
|
|
54
|
+
* the bridge's DOM action-sheet fallback. Set --everywhere-tint in your own CSS
|
|
55
|
+
* to brand the accent (it defaults to the platform accent color).
|
|
56
|
+
* ------------------------------------------------------------------------- */
|
|
57
|
+
|
|
58
|
+
:root {
|
|
59
|
+
--everywhere-tint: AccentColor;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Floating action button (everywhere_fab): fixed, safe-area-aware, above the
|
|
63
|
+
* home indicator and iOS floating tab bar. A real link/button, so it shows in
|
|
64
|
+
* a browser too. */
|
|
65
|
+
.everywhere-fab {
|
|
66
|
+
position: fixed;
|
|
67
|
+
right: max(var(--everywhere-inset-right), 1rem);
|
|
68
|
+
bottom: calc(var(--everywhere-inset-bottom) + var(--native-bottom-gap, 1rem));
|
|
69
|
+
z-index: 1000;
|
|
70
|
+
display: inline-flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: center;
|
|
73
|
+
gap: 0.5rem;
|
|
74
|
+
width: 3.5rem;
|
|
75
|
+
height: 3.5rem;
|
|
76
|
+
padding: 0;
|
|
77
|
+
border: 0;
|
|
78
|
+
border-radius: 9999px;
|
|
79
|
+
background: var(--everywhere-fab-bg, var(--everywhere-tint, #1a73e8));
|
|
80
|
+
color: var(--everywhere-fab-color, #fff);
|
|
81
|
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.28);
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
-webkit-tap-highlight-color: transparent;
|
|
84
|
+
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
|
85
|
+
}
|
|
86
|
+
.everywhere-fab:active {
|
|
87
|
+
transform: scale(0.94);
|
|
88
|
+
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
|
|
89
|
+
}
|
|
90
|
+
.everywhere-fab-left {
|
|
91
|
+
right: auto;
|
|
92
|
+
left: max(var(--everywhere-inset-left), 1rem);
|
|
93
|
+
}
|
|
94
|
+
/* Extended FAB: pill with a visible label beside the icon. */
|
|
95
|
+
.everywhere-fab-extended {
|
|
96
|
+
width: auto;
|
|
97
|
+
height: 3.25rem;
|
|
98
|
+
padding: 0 1.25rem;
|
|
99
|
+
font: inherit;
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
}
|
|
102
|
+
.everywhere-fab-icon {
|
|
103
|
+
width: 1.5rem;
|
|
104
|
+
height: 1.5rem;
|
|
105
|
+
flex: none;
|
|
106
|
+
}
|
|
107
|
+
.everywhere-fab-label {
|
|
108
|
+
white-space: nowrap;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* In-content action sheet (everywhere_menu) inline fallback: the items stay
|
|
112
|
+
* hidden until the trigger opens them. In the native shell the bridge shows a
|
|
113
|
+
* real action sheet instead and these never appear. */
|
|
114
|
+
[data-everywhere-menu-items] {
|
|
115
|
+
display: none;
|
|
116
|
+
}
|
|
117
|
+
[data-everywhere-menu-open] > [data-everywhere-menu-items],
|
|
118
|
+
[data-everywhere-menu-open] [data-everywhere-menu-items] {
|
|
119
|
+
display: block;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Nav-bar sources (everywhere_nav_button / everywhere_nav_menu) live in the
|
|
123
|
+
* top bar inside the shell, so hide the in-page copy there — but keep it in a
|
|
124
|
+
* browser, where there is no native bar. Keyed on the bridge's platform marker
|
|
125
|
+
* (set only by the mobile web bridge). */
|
|
126
|
+
html[data-bridge-platform] [data-everywhere-nav-button],
|
|
127
|
+
html[data-bridge-platform] [data-everywhere-nav-menu] {
|
|
128
|
+
display: none !important;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* DOM action-sheet fallback rendered by the bridge (Everywhere.menu / the
|
|
132
|
+
* everywhere_menu trigger) when there is no native shell. */
|
|
133
|
+
.everywhere-sheet-overlay {
|
|
134
|
+
position: fixed;
|
|
135
|
+
inset: 0;
|
|
136
|
+
z-index: 2000;
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: flex-end;
|
|
139
|
+
justify-content: center;
|
|
140
|
+
padding: 0.75rem;
|
|
141
|
+
padding-bottom: calc(var(--everywhere-inset-bottom) + 0.75rem);
|
|
142
|
+
background: rgba(0, 0, 0, 0.4);
|
|
143
|
+
animation: everywhere-sheet-fade 0.15s ease;
|
|
144
|
+
}
|
|
145
|
+
.everywhere-sheet {
|
|
146
|
+
width: 100%;
|
|
147
|
+
max-width: 30rem;
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
gap: 1px;
|
|
151
|
+
border-radius: 0.9rem;
|
|
152
|
+
overflow: hidden;
|
|
153
|
+
background: rgba(120, 120, 128, 0.2);
|
|
154
|
+
animation: everywhere-sheet-rise 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
155
|
+
}
|
|
156
|
+
.everywhere-sheet-header {
|
|
157
|
+
padding: 0.85rem 1rem;
|
|
158
|
+
text-align: center;
|
|
159
|
+
background: Canvas;
|
|
160
|
+
color: color-mix(in srgb, CanvasText 60%, transparent);
|
|
161
|
+
}
|
|
162
|
+
.everywhere-sheet-title {
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
color: CanvasText;
|
|
165
|
+
}
|
|
166
|
+
.everywhere-sheet-message {
|
|
167
|
+
font-size: 0.85rem;
|
|
168
|
+
margin-top: 0.15rem;
|
|
169
|
+
}
|
|
170
|
+
.everywhere-sheet-item {
|
|
171
|
+
appearance: none;
|
|
172
|
+
border: 0;
|
|
173
|
+
width: 100%;
|
|
174
|
+
padding: 1rem;
|
|
175
|
+
font: inherit;
|
|
176
|
+
font-size: 1.05rem;
|
|
177
|
+
text-align: center;
|
|
178
|
+
background: Canvas;
|
|
179
|
+
color: var(--everywhere-tint, #1a73e8);
|
|
180
|
+
cursor: pointer;
|
|
181
|
+
}
|
|
182
|
+
.everywhere-sheet-item:active {
|
|
183
|
+
background: color-mix(in srgb, CanvasText 8%, Canvas);
|
|
184
|
+
}
|
|
185
|
+
.everywhere-sheet-item:disabled {
|
|
186
|
+
color: color-mix(in srgb, CanvasText 30%, transparent);
|
|
187
|
+
cursor: default;
|
|
188
|
+
}
|
|
189
|
+
.everywhere-sheet-item-destructive {
|
|
190
|
+
color: #e5484d;
|
|
191
|
+
}
|
|
192
|
+
.everywhere-sheet-cancel {
|
|
193
|
+
margin-top: 0.5rem;
|
|
194
|
+
border-radius: 0.9rem;
|
|
195
|
+
font-weight: 600;
|
|
196
|
+
color: CanvasText;
|
|
197
|
+
}
|
|
198
|
+
@keyframes everywhere-sheet-fade {
|
|
199
|
+
from { opacity: 0; }
|
|
200
|
+
}
|
|
201
|
+
@keyframes everywhere-sheet-rise {
|
|
202
|
+
from { transform: translateY(1rem); opacity: 0; }
|
|
203
|
+
}
|