From 328ad632d35d57a7dfb62493a8fd231a470803ae Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 6 May 2026 19:09:52 -0400 Subject: [PATCH] feat: pull-to-refresh on Home and Library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS standalone PWAs don't get Safari's native pull-to-refresh, so add our own. New component handles the gesture: dampened drag past an 80px threshold triggers an async onRefresh; below that it springs back. Swipe direction is locked to the first 6px of movement, so horizontal carousel swipes (landscape Home) don't accidentally fire PTR. The arrow icon rotates from 0° to 180° as the pull approaches the threshold and turns primary-color when ready; during refresh a CSS spinner replaces it. - HomeView refreshes the device list (and sync status with it) - LibraryView refreshes images, pending-share count, devices, and the active shared sub-tab page when it's the one in view Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/PullToRefresh.vue | 186 ++++++++++++++++++ .../src/test/components/PullToRefresh.test.ts | 113 +++++++++++ frontend/src/views/HomeView.vue | 129 ++++++------ frontend/src/views/LibraryView.vue | 18 ++ .../build/assets/BaseBottomSheet-2W8WSuGe.js | 1 - .../build/assets/BaseBottomSheet-Baz8jxBn.js | 1 + public/build/assets/DevicePicker-B0Ct0KJt.js | 1 + public/build/assets/DevicePicker-ihOGtXzG.js | 1 - public/build/assets/HomeView-B_bLB2pc.js | 1 - ...iew-DrMhEL9c.css => HomeView-CvKnxi1X.css} | 2 +- public/build/assets/HomeView-DvMqv--T.js | 1 + public/build/assets/LibraryView-BXLhtHev.js | 1 + ...-Bf9KbHdL.css => LibraryView-COLjtUDo.css} | 2 +- public/build/assets/LibraryView-n4Q9RMCy.js | 1 - public/build/assets/PullToRefresh-DZt-0188.js | 1 + .../build/assets/PullToRefresh-Dh6ArHZZ.css | 1 + public/build/assets/SettingsView-9_y7I0FR.js | 1 + public/build/assets/SettingsView-Dw5J4-lx.js | 1 - public/build/assets/UploadView-BmbRnTmn.js | 1 + public/build/assets/UploadView-DV3YaT_8.js | 1 - ... => _plugin-vue_export-helper-CeYnMxKK.js} | 2 +- public/build/assets/index-BvMU-pbo.js | 16 ++ public/build/assets/index-CKYLXL2q.js | 16 -- public/build/index.html | 4 +- 24 files changed, 419 insertions(+), 83 deletions(-) create mode 100644 frontend/src/components/PullToRefresh.vue create mode 100644 frontend/src/test/components/PullToRefresh.test.ts delete mode 100644 public/build/assets/BaseBottomSheet-2W8WSuGe.js create mode 100644 public/build/assets/BaseBottomSheet-Baz8jxBn.js create mode 100644 public/build/assets/DevicePicker-B0Ct0KJt.js delete mode 100644 public/build/assets/DevicePicker-ihOGtXzG.js delete mode 100644 public/build/assets/HomeView-B_bLB2pc.js rename public/build/assets/{HomeView-DrMhEL9c.css => HomeView-CvKnxi1X.css} (84%) create mode 100644 public/build/assets/HomeView-DvMqv--T.js create mode 100644 public/build/assets/LibraryView-BXLhtHev.js rename public/build/assets/{LibraryView-Bf9KbHdL.css => LibraryView-COLjtUDo.css} (63%) delete mode 100644 public/build/assets/LibraryView-n4Q9RMCy.js create mode 100644 public/build/assets/PullToRefresh-DZt-0188.js create mode 100644 public/build/assets/PullToRefresh-Dh6ArHZZ.css create mode 100644 public/build/assets/SettingsView-9_y7I0FR.js delete mode 100644 public/build/assets/SettingsView-Dw5J4-lx.js create mode 100644 public/build/assets/UploadView-BmbRnTmn.js delete mode 100644 public/build/assets/UploadView-DV3YaT_8.js rename public/build/assets/{_plugin-vue_export-helper-DVo1OUMD.js => _plugin-vue_export-helper-CeYnMxKK.js} (81%) create mode 100644 public/build/assets/index-BvMU-pbo.js delete mode 100644 public/build/assets/index-CKYLXL2q.js diff --git a/frontend/src/components/PullToRefresh.vue b/frontend/src/components/PullToRefresh.vue new file mode 100644 index 0000000..0422b27 --- /dev/null +++ b/frontend/src/components/PullToRefresh.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/frontend/src/test/components/PullToRefresh.test.ts b/frontend/src/test/components/PullToRefresh.test.ts new file mode 100644 index 0000000..2b6b5a5 --- /dev/null +++ b/frontend/src/test/components/PullToRefresh.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import PullToRefresh from '@/components/PullToRefresh.vue' + +function touchEvent(type: string, x: number, y: number) { + const e = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent + Object.defineProperty(e, 'touches', { value: [{ clientX: x, clientY: y }] }) + Object.defineProperty(e, 'changedTouches', { value: [{ clientX: x, clientY: y }] }) + return e +} + +describe('PullToRefresh', () => { + it('renders an arrow icon by default and the slot content', () => { + const wrapper = mount(PullToRefresh, { + props: { onRefresh: () => Promise.resolve() }, + slots: { default: '

hello

' }, + }) + expect(wrapper.find('.ptr__arrow').exists()).toBe(true) + expect(wrapper.find('.ptr__spinner').exists()).toBe(false) + expect(wrapper.find('.content').exists()).toBe(true) + }) + + it('triggers onRefresh when the user drags past the threshold and releases', async () => { + const onRefresh = vi.fn(() => Promise.resolve()) + const wrapper = mount(PullToRefresh, { + props: { onRefresh, threshold: 50 }, + }) + const root = wrapper.find('.ptr').element as HTMLElement + root.dispatchEvent(touchEvent('touchstart', 100, 100)) + root.dispatchEvent(touchEvent('touchmove', 100, 250)) // dy=150, damped=75 (>50) + root.dispatchEvent(touchEvent('touchend', 100, 250)) + await flushPromises() + expect(onRefresh).toHaveBeenCalledTimes(1) + }) + + it('does not trigger onRefresh when the drag is below the threshold', async () => { + const onRefresh = vi.fn(() => Promise.resolve()) + const wrapper = mount(PullToRefresh, { + props: { onRefresh, threshold: 80 }, + }) + const root = wrapper.find('.ptr').element as HTMLElement + root.dispatchEvent(touchEvent('touchstart', 100, 100)) + root.dispatchEvent(touchEvent('touchmove', 100, 140)) // dy=40, damped=20 + root.dispatchEvent(touchEvent('touchend', 100, 140)) + await flushPromises() + expect(onRefresh).not.toHaveBeenCalled() + }) + + it('does not trigger onRefresh when isAtTop returns false', async () => { + const onRefresh = vi.fn(() => Promise.resolve()) + const wrapper = mount(PullToRefresh, { + props: { onRefresh, threshold: 50, isAtTop: () => false }, + }) + const root = wrapper.find('.ptr').element as HTMLElement + root.dispatchEvent(touchEvent('touchstart', 100, 100)) + root.dispatchEvent(touchEvent('touchmove', 100, 300)) + root.dispatchEvent(touchEvent('touchend', 100, 300)) + await flushPromises() + expect(onRefresh).not.toHaveBeenCalled() + }) + + it('bails when the user swipes horizontally instead of vertically', async () => { + const onRefresh = vi.fn(() => Promise.resolve()) + const wrapper = mount(PullToRefresh, { + props: { onRefresh, threshold: 50 }, + }) + const root = wrapper.find('.ptr').element as HTMLElement + root.dispatchEvent(touchEvent('touchstart', 100, 100)) + root.dispatchEvent(touchEvent('touchmove', 300, 105)) // big dx, tiny dy + root.dispatchEvent(touchEvent('touchmove', 300, 200)) // user keeps moving — should still bail + root.dispatchEvent(touchEvent('touchend', 300, 200)) + await flushPromises() + expect(onRefresh).not.toHaveBeenCalled() + }) + + it('shows the spinner while onRefresh is pending and clears it when resolved', async () => { + let resolveRefresh!: () => void + const onRefresh = vi.fn(() => new Promise(r => { resolveRefresh = r })) + const wrapper = mount(PullToRefresh, { + props: { onRefresh, threshold: 50 }, + }) + const root = wrapper.find('.ptr').element as HTMLElement + root.dispatchEvent(touchEvent('touchstart', 100, 100)) + root.dispatchEvent(touchEvent('touchmove', 100, 250)) + root.dispatchEvent(touchEvent('touchend', 100, 250)) + await wrapper.vm.$nextTick() + expect(wrapper.find('.ptr__spinner').exists()).toBe(true) + expect(wrapper.find('.ptr__arrow').exists()).toBe(false) + + resolveRefresh() + await flushPromises() + expect(wrapper.find('.ptr__spinner').exists()).toBe(false) + expect(wrapper.find('.ptr__arrow').exists()).toBe(true) + }) + + it('keeps the spinner up while onRefresh rejects, then clears it', async () => { + let rejectRefresh!: (e: unknown) => void + const onRefresh = vi.fn(() => new Promise((_, rej) => { rejectRefresh = rej })) + const wrapper = mount(PullToRefresh, { + props: { onRefresh, threshold: 50 }, + }) + const root = wrapper.find('.ptr').element as HTMLElement + root.dispatchEvent(touchEvent('touchstart', 100, 100)) + root.dispatchEvent(touchEvent('touchmove', 100, 250)) + root.dispatchEvent(touchEvent('touchend', 100, 250)) + await wrapper.vm.$nextTick() + expect(wrapper.find('.ptr__spinner').exists()).toBe(true) + + rejectRefresh(new Error('boom')) + await flushPromises() + expect(wrapper.find('.ptr__spinner').exists()).toBe(false) + }) +}) diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 6d339dc..cad98f4 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,70 +1,73 @@