diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6f211f994..b34fc81df 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -13,7 +13,7 @@ class ApplicationController < ActionController::Base before_action :maybe_redirect_to_setup, unless: :signed_in? before_action :authenticate_user!, unless: :devise_controller? - before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] && !turbo_infinite_scroll? } + before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO_REQUEST_ID'] && !turbo_infinite_scroll? } helper_method :button_title, :current_account, diff --git a/app/controllers/templates_dashboard_controller.rb b/app/controllers/templates_dashboard_controller.rb index b655caaca..faedcdfdb 100644 --- a/app/controllers/templates_dashboard_controller.rb +++ b/app/controllers/templates_dashboard_controller.rb @@ -110,16 +110,16 @@ class TemplatesDashboardController < ApplicationController pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0) - pagy = Pagy::Offset.new(count: folders_count + pinned_count, - page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max, - limit: FOLDERS_PER_PAGE, - raise_range_error: true) + pagy, = pagy(template_folders, count: folders_count + pinned_count, + page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max, + limit: FOLDERS_PER_PAGE, + raise_range_error: true) show_default_folder = show_default_in_grid && pagy.page == 1 show_shared_folder = show_shared_in_grid && pagy.page == 1 folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count - folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE + folder_limit = pagy.page == 1 ? pagy.limit - pinned_count : pagy.limit template_folders = template_folders.offset(folder_offset).limit(folder_limit) diff --git a/app/controllers/testing_accounts_controller.rb b/app/controllers/testing_accounts_controller.rb index f40440149..a96f21e54 100644 --- a/app/controllers/testing_accounts_controller.rb +++ b/app/controllers/testing_accounts_controller.rb @@ -10,22 +10,12 @@ class TestingAccountsController < ApplicationController impersonate_user(Accounts.find_or_create_testing_user(true_user.account)) - redirect_after_toggle + redirect_back(fallback_location: root_path) end def destroy stop_impersonating_user - redirect_after_toggle - end - - private - - def redirect_after_toggle - if turbo_native_app? - redirect_to root_path - else - redirect_back(fallback_location: root_path) - end + redirect_back(fallback_location: root_path) end end diff --git a/app/javascript/application.js b/app/javascript/application.js index d8f8f2bd3..91f60f003 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,4 +1,5 @@ import '@hotwired/turbo-rails' +import './elements/native_bridge' import { createApp, reactive } from 'vue' import TemplateBuilder from './template_builder/builder' @@ -97,14 +98,31 @@ if (window.webkit?.messageHandlers?.native) { } }) - document.addEventListener('turbo:before-fetch-request', (event) => { + document.addEventListener('native:pull-to-refresh', (event) => { const { session } = window.Turbo - const visit = session.navigator.currentVisit - const limit = document.getElementById('infinite_scroll')?.dataset.limit + const url = new URL(document.baseURI) - if (limit && window.scrollY > 0 && event.target === document.documentElement && visit?.location.href === session.view.lastRenderedLocation.href && session.view.isPageRefresh(visit)) { - event.detail.url.searchParams.set('limit', limit) - } + url.searchParams.delete('limit') + + session.history.replace(url, session.history.restorationIdentifier) + session.view.lastRenderedLocation = url + + event.detail.result = new Promise((resolve) => { + document.addEventListener('turbo:render', () => resolve(true), { once: true }) + document.addEventListener('turbo:fetch-request-error', () => resolve(true), { once: true }) + + session.refresh(document.baseURI) + }) + }) + + document.addEventListener('native:refresh', () => { + window.Turbo.session.refresh(document.baseURI) + }) + + document.addEventListener('native:turbo-stream', (e) => { + if (!e.detail.body?.trimStart().startsWith(' { @@ -118,8 +136,10 @@ if (window.webkit?.messageHandlers?.native) { }) } -document.addEventListener('turbo:before-fetch-request', (event) => { - event.detail.fetchOptions.headers['X-Turbo'] = 'true' +document.addEventListener('turbo:before-fetch-response', (event) => { + if (event.detail.fetchResponse.header('content-disposition')?.includes('attachment')) { + event.preventDefault() + } }) document.addEventListener('turbo:submit-end', async (event) => { const resp = event.detail?.formSubmission?.result?.fetchResponse?.response @@ -232,6 +252,7 @@ safeRegisterElement('template-builder', class extends HTMLElement { document.addEventListener('template-builder:select-submitter', this.onSelectSubmitter) document.addEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields) document.addEventListener('template-builder:draw-field', this.onDrawField) + document.addEventListener('native:builder-view', this.onBuilderView) document.addEventListener('turbo:before-cache', this.onBeforeCache) this.appElem = document.createElement('div') @@ -395,6 +416,23 @@ safeRegisterElement('template-builder', class extends HTMLElement { } } + onBuilderView = (event) => { + const root = document.documentElement.cloneNode(true) + const builder = root.querySelector('template-builder') + const style = document.createElement('style') + + builder.innerHTML = '' + builder.setAttribute('data-native-view', event.detail.view) + + root.querySelector('meta[name="viewport"]')?.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no') + + style.textContent = 'html { overflow-x: hidden; overscroll-behavior-x: none; } html, body { max-width: 100%; }' + + root.querySelector('head').appendChild(style) + + event.detail.result = '' + root.outerHTML + } + onBeforeCache = () => { this.app?.unmount() this.appElem?.remove() @@ -409,6 +447,7 @@ safeRegisterElement('template-builder', class extends HTMLElement { document.removeEventListener('template-builder:select-submitter', this.onSelectSubmitter) document.removeEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields) document.removeEventListener('template-builder:draw-field', this.onDrawField) + document.removeEventListener('native:builder-view', this.onBuilderView) document.removeEventListener('turbo:before-cache', this.onBeforeCache) this.app?.unmount() diff --git a/app/javascript/elements/infinite_scroll.js b/app/javascript/elements/infinite_scroll.js index e45466da0..e6a4e775e 100644 --- a/app/javascript/elements/infinite_scroll.js +++ b/app/javascript/elements/infinite_scroll.js @@ -1,8 +1,14 @@ -import { renderStreamMessage } from '@hotwired/turbo' +import { renderStreamMessage, session } from '@hotwired/turbo' export default class extends HTMLElement { connectedCallback () { - if (!this.dataset.src) return + if (this.dataset.limit) this.replaceLimitParam() + + if (!this.dataset.src) { + this.remove() + + return + } this.observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) { @@ -17,6 +23,15 @@ export default class extends HTMLElement { this.observer?.disconnect() } + replaceLimitParam () { + const url = new URL(window.location.href) + + url.searchParams.set('limit', this.dataset.limit) + + session.history.replace(url, session.history.restorationIdentifier) + session.view.lastRenderedLocation = url + } + load () { if (this.loading || !this.dataset.src) return diff --git a/app/javascript/elements/native_bridge.js b/app/javascript/elements/native_bridge.js new file mode 100644 index 000000000..a4369b6a7 --- /dev/null +++ b/app/javascript/elements/native_bridge.js @@ -0,0 +1,73 @@ +const handlers = window.webkit?.messageHandlers + +if (handlers?.native && window === window.top) { + handlers.native.postMessage({ type: 'reset' }) + + const reportPathConfiguration = () => { + const element = document.getElementById('native_path_configuration') + + if (element) handlers.native.postMessage({ type: 'path-configuration', json: element.textContent }) + } + + document.addEventListener('turbo:load', reportPathConfiguration) + + reportPathConfiguration() + + const reportFlash = (element) => { + if (!element) return + + if (element.dataset.notice) handlers.flash.postMessage({ style: 'notice', message: element.dataset.notice }) + if (element.dataset.alert) handlers.flash.postMessage({ style: 'alert', message: element.dataset.alert }) + + element.remove() + } + + const reportPageFlash = () => reportFlash(document.getElementById('native_flash')) + + new MutationObserver(reportPageFlash).observe(document.documentElement, { childList: true, subtree: true }) + + document.addEventListener('turbo:load', reportPageFlash) + + document.addEventListener('native:flash', (event) => { + reportFlash(new DOMParser().parseFromString(event.detail.html, 'text/html').getElementById('native_flash')) + }) + + reportPageFlash() + + document.addEventListener('click', (event) => { + if (document.querySelector('native-modal')) return + + const link = event.target.closest?.('a[data-turbo-frame="modal"], a[data-turbo-frame="drawer"]') + + if (!link?.href) return + + event.preventDefault() + event.stopPropagation() + + handlers.modal.postMessage({ action: 'open', url: link.href, modal: link.closest('[data-native-modal]')?.dataset.nativeModal || '' }) + }, true) + + document.addEventListener('click', (event) => { + const link = event.target.closest?.('a[href]') + + if (!link) return + + const title = link.dataset.nativeTitle || link.closest('[data-native-title]')?.dataset.nativeTitle || link.innerText.trim() + + if (!title) return + + const actions = link.closest('[data-native-actions]')?.dataset.nativeActions + + handlers.native.postMessage({ type: 'title', url: link.href, title, actions: actions ? actions.split(',') : null }) + }, true) + + document.addEventListener('native:action', (event) => { + document.querySelector(`[data-native-id="${event.detail.id}"]`)?.click() + }) + + document.addEventListener('native:title-bottom', (event) => { + const rect = document.querySelector('h1, h2')?.getBoundingClientRect() + + event.detail.result = rect?.height ? rect.bottom - document.documentElement.getBoundingClientRect().top : null + }) +} diff --git a/app/javascript/elements/native_search.js b/app/javascript/elements/native_search.js index 37bfe7bc1..aa9b00f1a 100644 --- a/app/javascript/elements/native_search.js +++ b/app/javascript/elements/native_search.js @@ -1,10 +1,36 @@ export default class extends HTMLElement { connectedCallback () { document.addEventListener('native:search', this.onSearch) + document.addEventListener('turbo:before-cache', this.onBeforeCache) + this.addEventListener('touchstart', this.onTouchStart, { passive: true }) + this.addEventListener('scroll', this.onScroll, { capture: true, passive: true }) + + const scroller = this.querySelector('[data-scroll-top]') + + if (scroller) scroller.scrollTop = scroller.dataset.scrollTop } disconnectedCallback () { document.removeEventListener('native:search', this.onSearch) + document.removeEventListener('turbo:before-cache', this.onBeforeCache) + } + + onBeforeCache = () => { + if (this.scroller) this.scroller.dataset.scrollTop = this.scroller.scrollTop + } + + onTouchStart = () => { + this.isTouching = true + } + + onScroll = (event) => { + this.scroller = event.target + + if (!this.isTouching) return + + this.isTouching = false + + window.webkit?.messageHandlers?.native?.postMessage({ type: 'dismiss-keyboard' }) } onSearch = (event) => { diff --git a/app/javascript/elements/native_upload.js b/app/javascript/elements/native_upload.js index f781b8567..869f74b6d 100644 --- a/app/javascript/elements/native_upload.js +++ b/app/javascript/elements/native_upload.js @@ -5,20 +5,36 @@ export default class extends HTMLElement { this.files = [] document.addEventListener('turbo:submit-end', this.onSubmitEnd) + document.addEventListener('native:upload-start', this.onUploadStart) + document.addEventListener('native:upload-chunk', this.onUploadChunk) + document.addEventListener('native:upload-submit', this.onUploadSubmit) } disconnectedCallback () { document.removeEventListener('turbo:submit-end', this.onSubmitEnd) + document.removeEventListener('native:upload-start', this.onUploadStart) + document.removeEventListener('native:upload-chunk', this.onUploadChunk) + document.removeEventListener('native:upload-submit', this.onUploadSubmit) } get form () { return this.querySelector('form') } - append (index, name, type, base64) { + onUploadStart = (event) => { + this.files = [] + + event.detail.result = true + } + + onUploadChunk = ({ detail: { index, name, type, chunk } }) => { const file = this.files[index] ||= { name, type, chunks: [] } - file.chunks.push(Uint8Array.from(atob(base64), (char) => char.charCodeAt(0))) + file.chunks.push(Uint8Array.from(atob(chunk), (char) => char.charCodeAt(0))) + } + + onUploadSubmit = (event) => { + event.detail.result = this.submit(event.detail.url) } async submit (url) { diff --git a/app/javascript/form.js b/app/javascript/form.js index 54760af75..c06af98e3 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -1,3 +1,5 @@ +import './elements/native_bridge' + import { createApp, reactive } from 'vue' import Form from './submission_form/form' diff --git a/app/javascript/template_builder/preview.vue b/app/javascript/template_builder/preview.vue index 14cf30732..5083110dd 100644 --- a/app/javascript/template_builder/preview.vue +++ b/app/javascript/template_builder/preview.vue @@ -16,13 +16,10 @@ v-if="editable" class="flex justify-between w-full" > -
+
-
+
<% else %>
- <%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug, host: form_link_host), class: 'btn btn-sm max-sm:normal-case btn-neutral text-white md:w-36 flex z-[1]', icon_class: 'w-6 h-6 max-sm:w-5 max-sm:h-5 text-white', copy_title: t('copy_link').length < 10 ? t('copy_link') : t('copy'), copy_title_md: t('link'), copied_title_md: t('copied') %> + <%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug, host: form_link_host), class: 'btn btn-sm max-sm:normal-case btn-neutral text-white md:w-36 flex z-[1]', icon_class: 'w-6 h-6 max-sm:w-[18px] max-sm:h-[18px] text-white', copy_title: t('copy_link').length < 10 ? t('copy_link') : t('copy'), copy_title_md: t('link'), copied_title_md: t('copied') %>
<% end %> <% end %> diff --git a/app/views/webhook_events/_drawer_events.html.erb b/app/views/webhook_events/_drawer_events.html.erb index 461ce0cb4..ec3b6baa5 100644 --- a/app/views/webhook_events/_drawer_events.html.erb +++ b/app/views/webhook_events/_drawer_events.html.erb @@ -52,9 +52,11 @@ <% unless webhook_event.status == 'pending' %>
- - <%= button_to button_title(title: t('resend'), disabled_with: t('awaiting'), icon: svg_icon('rotate', class: 'w-4 h-4'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), resend_settings_webhook_event_path(webhook_url.id, webhook_event.uuid), form: { id: button_uuid = SecureRandom.uuid }, params: { button_id: button_uuid }, class: 'btn btn-neutral btn-sm text-white', method: :post %> - + + + <%= button_to button_title(title: t('resend'), disabled_with: t('awaiting'), icon: svg_icon('rotate', class: 'w-4 h-4'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), resend_settings_webhook_event_path(webhook_url.id, webhook_event.uuid), form: { id: button_uuid = SecureRandom.uuid }, params: { button_id: button_uuid }, class: 'btn btn-neutral btn-sm text-white', method: :post %> + +
<% end %>
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 8343e491e..ce9183acf 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -1013,6 +1013,14 @@ en: &en phone_2fa_verification_text_message: Phone 2FA verification text message. notification_email_sent_to_the_sender_user_and_bcc_addresses_once_the_submission_is_completed_by_all_parties: Notification email sent to the sender user and BCC addresses once the submission is completed by all parties. completed_documents_copy_email_sent_to_recipients_upon_completion_containing_their_documents: Completed documents copy email sent to recipients upon completion containing their documents. + fields: Fields + more: More + ok: OK + failed_to_download_files: Failed to download files + failed_to_upload_files: Failed to upload files + failed_to_save_the_scan: Failed to save the scan + failed_to_scan_the_document: Failed to scan the document + document_scanning_is_not_available_on_this_device: Document scanning is not available on this device devise: confirmations: confirmed: Your email address has been successfully confirmed. @@ -2146,6 +2154,14 @@ es: &es open_file_from: 'Abrir archivo desde' cancel: Cancelar open: Abrir + fields: Campos + more: Más + ok: Aceptar + failed_to_download_files: No se pudieron descargar los archivos + failed_to_upload_files: No se pudieron subir los archivos + failed_to_save_the_scan: No se pudo guardar el escaneo + failed_to_scan_the_document: No se pudo escanear el documento + document_scanning_is_not_available_on_this_device: El escaneo de documentos no está disponible en este dispositivo devise: confirmations: confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente. @@ -3293,6 +3309,14 @@ it: &it open_file_from: 'Aprire file da' cancel: Annulla open: Apri + fields: Campi + more: Altro + ok: OK + failed_to_download_files: Impossibile scaricare i file + failed_to_upload_files: Impossibile caricare i file + failed_to_save_the_scan: Impossibile salvare la scansione + failed_to_scan_the_document: Impossibile scansionare il documento + document_scanning_is_not_available_on_this_device: La scansione dei documenti non è disponibile su questo dispositivo devise: confirmations: confirmed: Il tuo indirizzo email è stato confermato con successo. @@ -4423,6 +4447,14 @@ fr: &fr open_file_from: 'Ouvrir le fichier depuis' cancel: Annuler open: Ouvrir + fields: Champs + more: Plus + ok: OK + failed_to_download_files: Impossible de télécharger les fichiers + failed_to_upload_files: Impossible de téléverser les fichiers + failed_to_save_the_scan: Impossible d’enregistrer la numérisation + failed_to_scan_the_document: Impossible de numériser le document + document_scanning_is_not_available_on_this_device: La numérisation de documents n’est pas disponible sur cet appareil devise: confirmations: confirmed: Votre adresse e-mail a été confirmée avec succès. @@ -5569,6 +5601,14 @@ pt: &pt open_file_from: 'Abrir arquivo de' cancel: Cancelar open: Abrir + fields: Campos + more: Mais + ok: OK + failed_to_download_files: Não foi possível baixar os arquivos + failed_to_upload_files: Não foi possível enviar os arquivos + failed_to_save_the_scan: Não foi possível salvar a digitalização + failed_to_scan_the_document: Não foi possível digitalizar o documento + document_scanning_is_not_available_on_this_device: A digitalização de documentos não está disponível neste dispositivo devise: confirmations: confirmed: Seu endereço de e-mail foi confirmado com sucesso. @@ -6715,6 +6755,14 @@ de: &de open_file_from: 'Datei öffnen von' cancel: Abbrechen open: Öffnen + fields: Felder + more: Mehr + ok: OK + failed_to_download_files: Dateien konnten nicht heruntergeladen werden + failed_to_upload_files: Dateien konnten nicht hochgeladen werden + failed_to_save_the_scan: Scan konnte nicht gespeichert werden + failed_to_scan_the_document: Dokument konnte nicht gescannt werden + document_scanning_is_not_available_on_this_device: Das Scannen von Dokumenten ist auf diesem Gerät nicht verfügbar devise: confirmations: confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt. @@ -8385,6 +8433,14 @@ nl: &nl open_file_from: 'Bestand openen van' cancel: Annuleren open: Openen + fields: Velden + more: Meer + ok: OK + failed_to_download_files: Bestanden konden niet worden gedownload + failed_to_upload_files: Bestanden konden niet worden geüpload + failed_to_save_the_scan: Scan kon niet worden opgeslagen + failed_to_scan_the_document: Document kon niet worden gescand + document_scanning_is_not_available_on_this_device: Documenten scannen is niet beschikbaar op dit apparaat devise: confirmations: confirmed: Uw e-mailadres is succesvol bevestigd.