Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
1966171
feat(frontend): add offline support via service worker
netchampfaris Jul 5, 2026
8d1eb4f
feat(frontend): offline indicator and auto-refetch on reconnect
netchampfaris Aug 8, 2026
327d7ae
fix(frontend): wait for cache hydration before onboarding redirect wh…
netchampfaris Aug 8, 2026
56489a5
feat(frontend): offline fallbacks for uncached discussions and space …
netchampfaris Aug 8, 2026
94d311b
fix(profiles): accept query params in GP User Profile get_list
netchampfaris Aug 9, 2026
db554e1
feat(frontend): offline caching and fallbacks for people list and per…
netchampfaris Aug 9, 2026
57642e9
feat(frontend): background prefetch of members, profiles and avatars …
netchampfaris Aug 9, 2026
51d400f
feat(frontend): user-scoped offline cache clearing and service worker…
netchampfaris Aug 9, 2026
bda9da4
fix(frontend): rewarm app shell cache after user-switch cache clear
netchampfaris Aug 9, 2026
6b5c91c
test(frontend): add offline Playwright suite
netchampfaris Aug 9, 2026
256ab0b
Merge branch 'develop' into feat/offline-support
ebrahimgamdiwala Sep 7, 2026
940cc70
fix(frontend): await the user-switch cache clear before redirecting
ebrahimgamdiwala Sep 7, 2026
eb8ddb1
fix(frontend): stop offline route validation from proceeding with a n…
ebrahimgamdiwala Sep 7, 2026
ec387b4
fix(frontend): disable post/comment/poll submit buttons while offline
ebrahimgamdiwala Sep 7, 2026
3ed52d6
feat(frontend): redesign offline indicator as a full-width status banner
ebrahimgamdiwala Sep 7, 2026
580103f
fix(frontend): use theme-adaptive gray tokens on the offline banner
ebrahimgamdiwala Sep 7, 2026
3b6008a
fix(frontend): shorten offline banner text to just "Offline"
ebrahimgamdiwala Sep 9, 2026
a6d26a6
fix(frontend): honor the service worker's cache-clear success signal
ebrahimgamdiwala Sep 9, 2026
ae4e01c
fix(frontend): catch worker communication failures before the cache f…
ebrahimgamdiwala Sep 9, 2026
680aaff
fix(frontend): invalidate the profile-bento cache after save/reset
ebrahimgamdiwala Sep 16, 2026
3cab53a
test(frontend): wait for the activity-feed reload in the rename test
ebrahimgamdiwala Sep 16, 2026
fdb8375
Merge remote-tracking branch 'upstream/develop' into pr516-merged
ebrahimgamdiwala Sep 16, 2026
dc70eb7
Revert "test(frontend): wait for the activity-feed reload in the rena…
ebrahimgamdiwala Sep 16, 2026
8ecc748
fix(frontend): debounce duplicate activity-feed reload triggers
ebrahimgamdiwala Sep 16, 2026
c9644a7
fix(frontend): don't erase the shared-computer switch marker on a log…
ebrahimgamdiwala Sep 17, 2026
2236b04
fix(frontend): recover navigation after a chunk failed to load offline
ebrahimgamdiwala Sep 17, 2026
7026ac3
fix(frontend): stop lists showing another space's content when offline
ebrahimgamdiwala Sep 17, 2026
89f657e
fix(frontend): disable write actions while offline instead of erroring
ebrahimgamdiwala Sep 17, 2026
e27bb26
fix(frontend): stop background member prefetch and double app load
ebrahimgamdiwala Sep 17, 2026
058814d
fix(frontend): offline feedback on disabled actions and honest empty …
ebrahimgamdiwala Sep 17, 2026
48074fa
feat(frontend): stale-while-revalidate while offline
ebrahimgamdiwala Sep 17, 2026
58bf4f4
refactor(frontend): rename offline revalidation module
ebrahimgamdiwala Sep 17, 2026
771e0eb
fix(frontend): use reactive online state in offline fallbacks
ebrahimgamdiwala Sep 17, 2026
7d25069
feat(offline): endpoints for downloading recent discussions
ebrahimgamdiwala Sep 18, 2026
d624f92
feat(offline): download recent discussions for offline reading
ebrahimgamdiwala Sep 18, 2026
0c1dd9c
fix(frontend): quiet failures when the server can't be reached
ebrahimgamdiwala Sep 18, 2026
891be47
feat(offline): remove visited discussions the user can no longer read
ebrahimgamdiwala Sep 18, 2026
b81245b
fix(offline): download discussions missing from the device, not just …
ebrahimgamdiwala Sep 18, 2026
ca8db14
feat(offline): download images and custom emojis with discussions
ebrahimgamdiwala Sep 18, 2026
460401b
fix(sw): delete old build files from the asset cache
ebrahimgamdiwala Sep 18, 2026
abeab0b
test(offline): fix US2, US3 and US4
ebrahimgamdiwala Sep 18, 2026
748f387
fix(sw): fetch the build manifest only when the build changes
ebrahimgamdiwala Sep 18, 2026
3034f5f
feat(offline): offline settings on mobile
ebrahimgamdiwala Sep 18, 2026
5cc5036
refactor(offline): trim comments, drop dead router checks, share fail…
ebrahimgamdiwala Sep 18, 2026
4e13b19
fix(sw): stop refetching saved images on every view
ebrahimgamdiwala Sep 18, 2026
46f5764
fix(offline): keep saved images another discussion still shows
ebrahimgamdiwala Sep 18, 2026
8203fda
fix(tests): reset single doctypes in the Cypress data wipe
ebrahimgamdiwala Sep 18, 2026
4b1da9f
refactor(offline): drop GP Settings, move offline settings to Prefere…
ebrahimgamdiwala Sep 18, 2026
bcc7232
fix(pages): lay out the pages load failure like every other list
ebrahimgamdiwala Sep 18, 2026
f5124f4
fix(offline): keep repeated download and update toasts to one row
ebrahimgamdiwala Sep 18, 2026
fc5050c
fix(search): show the shared offline state instead of a red error
ebrahimgamdiwala Sep 21, 2026
5076a20
Merge remote-tracking branch 'upstream/develop' into pr516-merged
ebrahimgamdiwala Sep 21, 2026
0c06eb6
fix(offline): route the last-post reminder through the offline wrappers
ebrahimgamdiwala Sep 21, 2026
b0a9eb1
test(offline): cover the offline search state in US6
ebrahimgamdiwala Sep 21, 2026
bd03574
style(offline): capitalise Spaces in the offline download copy
ebrahimgamdiwala Sep 21, 2026
b2b501a
feat(offline): download a joined community, not just joined Spaces
ebrahimgamdiwala Sep 21, 2026
369ce3f
fix(offline): fill the feeds a downloaded Space needs to list offline
ebrahimgamdiwala Sep 21, 2026
a2275a0
fix(offline): keep the offline settings reachable and their figures c…
ebrahimgamdiwala Sep 21, 2026
b14c273
perf(offline): let the index decide what a sync fetches
ebrahimgamdiwala Sep 21, 2026
31f19fd
Merge remote-tracking branch 'upstream/develop' into pr516-merged
ebrahimgamdiwala Sep 21, 2026
6e693a9
fix(offline): keep cached feeds honest about what belongs in them
ebrahimgamdiwala Sep 21, 2026
d051ea8
fix(offline): keep a device's feeds to what the server says belongs i…
ebrahimgamdiwala Sep 21, 2026
98c4e0d
fix(offline): close the gaps a review of this branch turned up
ebrahimgamdiwala Sep 21, 2026
ba5ed7c
fix(offline): hide the device section until a window is picked
ebrahimgamdiwala Sep 22, 2026
15c7dd6
fix(offline): say what the storage figure actually covers
ebrahimgamdiwala Sep 22, 2026
3e27d27
perf(offline): keep the index off the filesort a wide Space list forces
ebrahimgamdiwala Sep 22, 2026
ecdaefc
fix(offline): project only the columns the offline cache is built from
ebrahimgamdiwala Sep 22, 2026
5e6febe
fix(offline): check the shape of a field list at every level
ebrahimgamdiwala Sep 22, 2026
c3c5ea3
refactor(offline): a pass with frappe's review and code-style skills
ebrahimgamdiwala Sep 22, 2026
3be7ccd
fix(offline): refuse a download the connection cannot serve
ebrahimgamdiwala Sep 22, 2026
0a94ba5
docs(offline): tighten the longest comments
ebrahimgamdiwala Sep 22, 2026
d44e1df
fix(offline): let the banner say it, without a toast as well
ebrahimgamdiwala Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ cypress/results
cypress/screenshots
cypress/videos
cypress/downloads
tests/offline/results
coverage
.nyc_output
.nyc_merged
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"serve": "vite preview",
"test-local": "cypress open --e2e --browser chrome",
"test": "cypress run",
"test:offline": "node tests/offline/runner.js",
"test:offline:smoke": "node tests/offline/smoke-online.js && node tests/offline/smoke-online-people.js && node tests/offline/smoke-online-shared-computer.js",
"coverage:merge": "rm -rf .nyc_merged && mkdir -p .nyc_merged && cp coverage/baseline.json .nyc_merged/baseline.json && cp .nyc_output/out.json .nyc_merged/out.json && nyc report --nycrc-path \"$PWD/.nycrc.json\" --temp-dir \"$PWD/.nyc_merged\" --cwd \"${COVERAGE_ROOT:-$PWD}\" --report-dir \"$PWD/coverage\""
},
"dependencies": {
Expand Down Expand Up @@ -42,6 +44,7 @@
"cypress": "14.5.1",
"mocha-junit-reporter": "^2.2.1",
"nyc": "^18.0.0",
"playwright": "^1.62.1",
"postcss": "^8.4.5",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
flip isFinished back to false and unmount the open settings dialog. -->
<SettingsDialog v-if="$session.isLoggedIn && usersReady" />
<component :is="DevUserSwitcher" v-if="DevUserSwitcher && $session.isLoggedIn && usersReady" />
<OfflineIndicator />
</FrappeUIProvider>
</template>

Expand All @@ -27,6 +28,7 @@ import { useTheme } from '@/utils/useTheme'
import { useCursorStyle } from '@/utils/useCursorStyle'
import NewTaskDialog from './components/NewTaskDialog/NewTaskDialog.vue'
import SettingsDialog from './components/Settings/SettingsDialog.vue'
import OfflineIndicator from './components/OfflineIndicator.vue'
import { settingsBackgroundPath } from './components/Settings'
import { getHomeRoute } from '@/router'

Expand Down
34 changes: 30 additions & 4 deletions frontend/src/components/CommentsArea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@
variant: 'solid',
onClick: submitComment,
loading: comments.insert.loading,
disabled: commentEmpty,
disabled: commentEmpty || !isOnline,
}"
:discardButtonProps="{
onClick: discardComment,
Expand Down Expand Up @@ -237,6 +237,7 @@
:submitButtonProps="{
onClick: submitPoll,
loading: polls.insert.loading,
disabled: !isOnline,
}"
:discardButtonProps="{
onClick: discardPoll,
Expand Down Expand Up @@ -288,6 +289,7 @@ import { tags } from '@/data/tags'
import { isNewCommentOpen } from '@/data/newComment'
import { useRichQuotes } from '@/components/RichQuoteExtension/useRichQuotes'
import { useDraftSync } from '@/data/useDraftSync'
import { isOnline, onReconnect } from '@/data/online'
import { useSessionUser } from '@/data/users'
import type { Space } from '@/data/spaces'
import { useIsMobile } from '@/utils/useIsMobile'
Expand Down Expand Up @@ -401,7 +403,11 @@ const composerStorageKey = computed(() => {

const comments = useList<GPComment>({
doctype: 'GP Comment',
cacheKey: ['Comments', props.doctype, props.name],
// Scoped to the session user: a discussion's comments can live in a private space,
// so a second account on the same browser must not see them cached offline before
// its own permission-checked fetch resolves (review finding from PR #516).
cacheKey: ['Comments', props.doctype, props.name, sessionUser.name],
staleOnError: true,
fields: [
'name',
'content',
Expand Down Expand Up @@ -437,6 +443,8 @@ const comments = useList<GPComment>({

const activities = useList<GPActivity>({
doctype: 'GP Activity',
cacheKey: ['Activities', props.doctype, props.name, sessionUser.name],
staleOnError: true,
fields: ['name', 'user', 'action', 'data', 'creation'],
filters: {
reference_doctype: props.doctype,
Expand Down Expand Up @@ -468,6 +476,8 @@ watch(

const polls = useList<GPPoll>({
doctype: 'GP Poll',
cacheKey: ['Polls', props.name, sessionUser.name],
staleOnError: true,
fields: [
'name',
'title',
Expand Down Expand Up @@ -502,6 +512,19 @@ watchEffect(() => {
}
})

// US5 (seamless recovery): while offline, comments/activity/polls posted by
// other users never arrive — the socket that normally pushes them is down too.
// Reload this discussion's timeline once the browser comes back online.
// Unregistered on unmount: this callback closes over lists owned by this
// component instance, and there's no reason to keep refetching an open
// discussion the user has already navigated away from.
const unregisterReconnect = onReconnect(() => {
comments.reload()
activities.reload()
polls.reload()
})
onUnmounted(unregisterReconnect)

// Computed
const timelineItems = computed(() => {
let items: Array<GPComment | GPActivity | GPPoll> = []
Expand Down Expand Up @@ -667,7 +690,10 @@ function resetCommentState() {
}

async function submitComment() {
if (commentEmpty.value || comments.insert.loading) return
// The submit button is disabled while offline, but ctrl/cmd+Enter (bound below on
// the editor) reaches this directly and isn't gated by that - guard here too, rather
// than let it hit the network and surface a raw "Failed to fetch".
if (commentEmpty.value || comments.insert.loading || !isOnline.value) return

const comment = await comments.insert.submit({
reference_doctype: props.doctype,
Expand Down Expand Up @@ -748,7 +774,7 @@ function wait(ms: number) {
}

function submitPoll() {
if (props.doctype !== 'GP Discussion') return
if (props.doctype !== 'GP Discussion' || !isOnline.value) return
return polls.insert
.submit({
discussion: props.name,
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/components/CommentsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ import { subscribeToDoc, useSocket, type NewActivityEvent } from '@/socket'
import { GPActivity, GPComment } from '@/types/doctypes'
import type { Space } from '@/data/spaces'
import { useDraftSync } from '@/data/useDraftSync'
import { onReconnect } from '@/data/online'
import { session } from '@/data/session'

interface Props {
doctype: string
Expand Down Expand Up @@ -171,7 +173,11 @@ const comments = useList<
>
>({
doctype: 'GP Comment',
cacheKey: ['Comments', props.doctype, props.name],
// Scoped to the session user: a discussion's comments can live in a private space,
// so a second account on the same browser must not see them cached offline before
// its own permission-checked fetch resolves (review finding from PR #516).
cacheKey: ['Comments', props.doctype, props.name, session.user],
staleOnError: true,
fields: [
'name',
'content',
Expand Down Expand Up @@ -212,6 +218,8 @@ interface Activity extends Pick<GPActivity, 'name' | 'user' | 'action' | 'creati

const activities = useList<Activity>({
doctype: 'GP Activity',
cacheKey: ['Activities', props.doctype, props.name, session.user],
staleOnError: true,
fields: ['name', 'user', 'action', 'data', 'creation'],
filters: {
reference_doctype: props.doctype,
Expand All @@ -228,6 +236,14 @@ const activities = useList<Activity>({
},
})

// US5 (seamless recovery): mirrors the same reconnect reload in CommentsArea.vue
// (discussion comments) for this task's comment/activity timeline.
const unregisterReconnect = onReconnect(() => {
comments.reload()
activities.reload()
})
onUnmounted(unregisterReconnect)

// Computed
type GroupedActivity = {
doctype: 'GP Activity'
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/components/DiscussionView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,16 @@
again.
</p>
</EmptyStateBox>
<!-- Same catch-all as below, but for the offline/network case: name the actual reason
(never cached, can't reach the server) instead of the generic "something went
wrong", and offer a Retry rather than telling the user to refresh. -->
<OfflineContentFallback
v-else-if="discussion.isFinished && isOfflineFailure"
class="mx-auto mt-14 max-w-2xl px-6"
title="This discussion isn't available offline"
message="It hasn't been saved for offline use yet. Reconnect and retry to load it."
@retry="discussion.reload()"
/>
<!-- Fetch finished, but there is no doc and no recognised not-found/forbidden error.
Fail visibly instead of rendering a blank page. Gated on isFinished so the
pre-fetch tick (useFetch defers its first execute by a microtask) doesn't flash
Expand Down Expand Up @@ -352,7 +362,9 @@ import UserProfileLink from './UserProfileLink.vue'
const RevisionsDialog = defineAsyncComponent(() => import('./RevisionsDialog.vue'))
import SpaceBreadcrumbs from './SpaceBreadcrumbs.vue'
import EmptyStateBox from './EmptyStateBox.vue'
import OfflineContentFallback from './OfflineContentFallback.vue'
import { copyToClipboard, isEditorContentEmpty } from '@/utils'
import { isBrowserOffline, isNetworkError } from '@/offline'
import { getSpace, useSpace } from '@/data/spaces'
import { useCommunity } from '@/data/communities'
import { useGroupedSpaceOptions } from '@/data/groupedSpaces'
Expand Down Expand Up @@ -408,6 +420,9 @@ function isMissingOrForbidden(error: unknown): boolean {
const type = (error as { type?: string } | null)?.type
return type === 'DoesNotExistError' || type === 'PermissionError'
}
// A network failure (offline, or the request never reached the server) deserves its own
// copy and a Retry — telling someone offline to "refresh" is misleading busywork.
const isOfflineFailure = computed(() => isBrowserOffline() || isNetworkError(discussion.error))
const showTitleInMobileHeader = ref(false)
const mobileHeaderTitle = computed(() =>
showTitleInMobileHeader.value ? discussion.doc?.title || 'Discussion' : 'Discussion',
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/LastPostReminder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ import { useCall } from 'frappe-ui'
import { dayjs } from 'frappe-ui'
import { computed } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { session } from '@/data/session'
let lastPostAt = useCall<string>({
url: `/api/v2/method/GP User Profile/get_last_post`,
cacheKey: 'last_post_at',
// Scoped to the session user so a second account on the same browser can't read the
// first account's cached "last post" date while offline (review finding from PR #516).
cacheKey: ['last_post_at', session.user],
staleOnError: true,
})

const daysSinceLastPost = computed(() => {
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/OfflineContentFallback.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<template>
<EmptyStateBox>
<LucideTriangleAlert class="mb-3 size-7 text-ink-gray-4" />
<div class="text-base text-ink-gray-7">{{ title }}</div>
<p class="mt-2 max-w-md text-center text-p-sm text-ink-gray-5">{{ message }}</p>
<Button class="mt-4" icon-left="lucide-refresh-cw" @click="emit('retry')"> Retry </Button>
</EmptyStateBox>
</template>

<script setup lang="ts">
// Presentational-only: callers decide *when* to show this (loading/empty/error
// state belongs to the resource, not this component) and *what* to say (offline
// vs. generic copy, via isBrowserOffline()/isNetworkError() from '@/offline').
// Kept generic so any failed useList/useDoc fetch can reuse it instead of each
// page hand-rolling its own dead-end card.
import EmptyStateBox from './EmptyStateBox.vue'

defineProps<{
title: string
message: string
}>()

const emit = defineEmits<{ retry: [] }>()
</script>
43 changes: 43 additions & 0 deletions frontend/src/components/OfflineIndicator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<template>
<!-- Teleported + fixed to the true viewport top so it renders above both
MobileShell and DesktopShell (frappe-ui), not inside either one - see the
`data-offline` attribute this sets below and the matching `[data-slot=...]`
rules in index.css, which push the shells' own content down by exactly this
banner's height. That's what keeps it from ever overlapping the header,
search, nav, or anything else already on screen, instead of covering it. -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 -translate-y-full"
leave-active-class="transition duration-150 ease-in"
leave-to-class="opacity-0 -translate-y-full"
>
<div
v-if="!isOnline"
role="status"
class="fixed inset-x-0 top-0 z-[60] flex h-[var(--offline-banner-height)] items-center justify-center gap-1.5 bg-surface-gray-3 px-3 text-p-sm text-ink-gray-7"
>
<span class="lucide-wifi-off size-3.5 shrink-0" aria-hidden="true" />
<span class="font-medium text-ink-gray-8">Network offline.</span>
<span class="font-normal text-ink-gray-5">Showing saved content.</span>
</div>
</Transition>
</Teleport>
</template>

<script setup lang="ts">
import { watch } from 'vue'
import { isOnline } from '@/data/online'

// A DOM attribute, not a Vue-scoped style: the shells this needs to push down
// (MobileShell.vue, DesktopShell.vue) live in frappe-ui, outside this component's
// own render tree, so index.css targets them by this attribute + their own
// `data-slot` hooks instead.
watch(
isOnline,
(online) => {
document.documentElement.toggleAttribute('data-offline', !online)
},
{ immediate: true },
)
</script>
Loading
Loading