Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions docs/memo/notification_roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ graph LR
- [x] Deep link URL 埋め込み (`?focusBlock={block_id}` を FCM `data.link` に付与)
- [x] フォアグラウンド OS 通知 (`useForegroundNotificationToast` → `registration.showNotification`)

### Phase 6b-9: 未実装 (別 PR / Follow-up)
### Phase 6b-9

- [ ] SW `notificationclick` ハンドラ (tap 時に `focusBlock` を消費してブロックまでスクロール)
- [x] SW `notificationclick` ハンドラ (#207): tap で既存 client に postMessage → SPA navigate、無ければ openWindow。client 側は `useFocusBlockOnMount` で block まで scroll
- [ ] Phase 7: stg 環境 E2E テスト (iOS/Android/Desktop 実機)
- [ ] Phase 8: prod リリース + Cloud Scheduler ジョブ作成
- [ ] Phase 9 (#173): `sent_notifications` 掃除 cron
Expand Down
2 changes: 1 addition & 1 deletion docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ tick が 60 秒を超えると次 tick と重なる。閾値と対処:
- 素材: FA (frontend/src/assets/icons) + Lucide MapPin。オレンジバッジ (`#f4a261`) + 白抜き
- **Badge** (Android status bar 等の小モノクロアイコン、96px、透過 PNG): `badge.png` を全通知で共通使用。紙飛行機シルエット (favicon 由来)、OS 側でアクセントカラーへリカラーされる。icon と別 URL にしないと Android で四角い塗りになる
- 生成スクリプト: `scripts/gen_notification_icons.py`
- **Deep link**: `/trip/{urlId}?focusBlock={blockId}` (該当ブロックにスクロール、Phase 2 で完全実装)
- **Deep link**: `/trip/{urlId}?focusBlock={blockId}`。SW `notificationclick` (`frontend/public/firebase-messaging-sw.js`) が受け、既存 PWA/tab (focused > visible > 任意) に postMessage で client-side navigate、無ければ `clients.openWindow`。client 側は `useFocusBlockOnMount` で `useBlock(id)` から pageId を得て page 切替 → `[data-block-id]` を rAF 待機して `scrollIntoView` (center)。処理後 `focusBlock` は replaceState で除去。

## 6. プラットフォーム対応

Expand Down
24 changes: 24 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
"source": "**",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/firebase-messaging-sw.js",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
}
]
},
{
Expand All @@ -20,6 +28,14 @@
"source": "**",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/firebase-messaging-sw.js",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
}
]
},
{
Expand All @@ -31,6 +47,14 @@
"source": "**",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/firebase-messaging-sw.js",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
}
]
}
]
Expand Down
68 changes: 68 additions & 0 deletions frontend/public/firebase-messaging-sw.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,74 @@
// biome-ignore-all lint/correctness/noUndeclaredVariables: firebase / importScripts は SW context の global
// biome-ignore-all lint/correctness/noUnusedFunctionParameters: onBackgroundMessage の signature は Firebase 規定

// notificationclick handler は Firebase SDK の import / init より前に登録する。
// Firebase Messaging SDK は内部で notificationclick リスナーを付け stopImmediatePropagation する
// ことがあるため、後付けだと invocation されないケースがある。

// SW default lifecycle だと install → waiting、既存 client が全部閉じるまで activate されない。
// FCM SW は root scope 外なので待たせるとユーザ操作なしに切替できず更新が届かない。
self.addEventListener('install', event => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});

// Firebase Admin SDK の WebpushFCMOptions(link=...) は SDK 12.x で
// data.FCM_MSG.notification.click_action に写される (fcmOptions.link ではない)。
// data.link はフォアグラウンド通知 (useForegroundNotificationToast) の自前 showNotification 経由。
const extractDeepLink = notification => {
const data = notification?.data ?? {};
const fcm = data?.FCM_MSG;
return fcm?.notification?.click_action ?? fcm?.fcmOptions?.link ?? data?.link ?? null;
};

// PWA / ブラウザ tab 両方 push 登録している端末で、ユーザが今触ってる方を選ぶための優先度。
const pickTargetClient = clientsList => {
return (
clientsList.find(c => c.focused) ?? clientsList.find(c => c.visibilityState === 'visible') ?? clientsList[0] ?? null
);
};

self.addEventListener('notificationclick', event => {
event.notification.close();

const link = extractDeepLink(event.notification);
if (!link) return;

// payload 経路経由の open-redirect を防ぐ (別 origin URL は捨てる)。
let targetUrl;
try {
targetUrl = new URL(link, self.location.origin);
} catch {
return;
}
if (targetUrl.origin !== self.location.origin) return;

event.waitUntil(
(async () => {
const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const target = pickTargetClient(clientsList);

if (target) {
// WindowClient.navigate() を使うとフルリロードで atom / SWR / scroll 位置が飛ぶので、
// 代わりに client 側で React Router の navigate を呼んでもらう。
target.postMessage({ type: 'FCM_NAVIGATE', url: targetUrl.href });
try {
await target.focus();
} catch {
// focus はユーザ操作起源でないと reject されるが postMessage は届いてるので許容
}
return;
}

// task-killed 状態など matchAll が 0 件のときは openWindow が唯一の起動経路。
// Chrome は URL を尊重して PWA を起動 (実機で verify 済み)。
await self.clients.openWindow(targetUrl.href);
})()
);
});

importScripts('https://www.gstatic.com/firebasejs/12.16.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/12.16.0/firebase-messaging-compat.js');

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom';
import { isOfflineReadAtom } from './atoms/network';
import { NoIndex } from './components/NoIndex';
import { Title } from './components/Title';
import { useFcmNavigationListener } from './hooks/useFcmNavigationListener';
import { useForegroundNotificationToast } from './hooks/useForegroundNotificationToast';
import { useNetworkToast } from './hooks/useNetworkToast';
import { usePageTracking } from './hooks/usePageTracking';
Expand All @@ -19,6 +20,7 @@ const App = () => {
useNetworkToast();
usePageTracking();
useForegroundNotificationToast();
useFcmNavigationListener();

return (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function BlockScheduleView({ block, isNow, className }: BlockScheduleView
return (
<div
ref={resizeRef}
data-block-id={block.id}
className={cn(
'relative flex w-full flex-col gap-2 rounded-lg bg-linear-to-r from-teal-400 px-4 py-2',
isHovered ? 'to-teal-400' : 'to-teal-500',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function BlockTransportationView({ block, isNow, className }: BlockTransp
return (
<div
ref={resizeRef}
data-block-id={block.id}
className={cn(
'relative flex w-full flex-col gap-2 rounded-lg bg-gradient-to-r from-sky-50 px-2 py-2 sm:px-4',
isHovered ? 'to-sky-50' : 'to-sky-100',
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/hooks/useBlocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,22 @@ const BLOCKS_BASE_PATH = '/blocks';
* pageId に紐づく Block をすべて取得するフック
*/
export const useBlocks = (pageId: number | null, options?: Pick<SWRConfiguration, 'refreshInterval'>) => {
const { mutate: globalMutate, cache } = useSWRConfig();
const { data, error, isLoading } = useSWR<Block[]>(
pageId ? `${PAGES_BASE_PATH}/${pageId}/blocks` : null,
async (url: string) => {
const res = await fetcher(url);
return z.array(blockFromApi).parse(res);
const parsed = z.array(blockFromApi).parse(res);
// list fetch で得た block を個別 key にも撒いて useBlock(id) の重複 fetch を避ける
// (通知タップの deep link 解決経路で効く)。ただし既存値は上書きしない:
// 楽観更新中の値を list revalidation の古いサーバ値で巻き戻すのを防ぐため。
for (const block of parsed) {
const individualKey = `${BLOCKS_BASE_PATH}/${block.id}`;
if (cache.get(individualKey)?.data === undefined) {
globalMutate(individualKey, block, { revalidate: false });
}
}
return parsed;
},
options
);
Expand Down
119 changes: 119 additions & 0 deletions frontend/src/hooks/useFcmNavigationListener.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockNavigate = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));

import { useFcmNavigationListener } from '@/hooks/useFcmNavigationListener';

type SwEventListener = (event: MessageEvent) => void;

const swListeners = new Set<SwEventListener>();

const swStub = {
addEventListener: (type: string, listener: SwEventListener) => {
if (type === 'message') swListeners.add(listener);
},
removeEventListener: (type: string, listener: SwEventListener) => {
if (type === 'message') swListeners.delete(listener);
},
getRegistration: async () => undefined,
};

const dispatchSwMessage = (data: unknown) => {
const event = { data } as MessageEvent;
for (const listener of swListeners) listener(event);
};

describe('useFcmNavigationListener', () => {
beforeEach(() => {
swListeners.clear();
mockNavigate.mockClear();
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: swStub,
});
});

afterEach(() => {
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: undefined,
});
});

it('FCM_NAVIGATE メッセージ受信で navigate() を pathname+search+hash 付きで呼ぶ', () => {
renderHook(() => useFcmNavigationListener());

dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'http://localhost:3000/trip/abc?focusBlock=42#top' });

expect(mockNavigate).toHaveBeenCalledExactlyOnceWith('/trip/abc?focusBlock=42#top');
});

it('FCM_NAVIGATE 以外のメッセージは無視する', () => {
renderHook(() => useFcmNavigationListener());

dispatchSwMessage({ type: 'SOME_OTHER', url: 'http://localhost:3000/trip/xxx' });

expect(mockNavigate).not.toHaveBeenCalled();
});

it('別 origin の URL は無視する (open-redirect 対策)', () => {
renderHook(() => useFcmNavigationListener());

dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'https://evil.example.com/trip/abc' });

expect(mockNavigate).not.toHaveBeenCalled();
});

it('非 http scheme (javascript:) は同 origin にならず遷移しない', () => {
renderHook(() => useFcmNavigationListener());

dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'javascript:alert(1)' });

expect(mockNavigate).not.toHaveBeenCalled();
});

it('url が string でなければ無視する', () => {
renderHook(() => useFcmNavigationListener());

dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 42 });

expect(mockNavigate).not.toHaveBeenCalled();
});

it('data が undefined でも throw しない', () => {
renderHook(() => useFcmNavigationListener());

expect(() => dispatchSwMessage(undefined)).not.toThrow();
expect(mockNavigate).not.toHaveBeenCalled();
});

it('unmount 時に listener を解除する', () => {
const { unmount } = renderHook(() => useFcmNavigationListener());

expect(swListeners.size).toBe(1);
unmount();
expect(swListeners.size).toBe(0);
});

it('navigator.serviceWorker が無い環境でも throw しない', () => {
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: undefined,
});

expect(() => renderHook(() => useFcmNavigationListener())).not.toThrow();
expect(swListeners.size).toBe(0);
});

it('現在 URL と一致するときは navigate しない', () => {
renderHook(() => useFcmNavigationListener());

dispatchSwMessage({ type: 'FCM_NAVIGATE', url: window.location.href });

expect(mockNavigate).not.toHaveBeenCalled();
});
});
62 changes: 62 additions & 0 deletions frontend/src/hooks/useFcmNavigationListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

/**
* firebase-messaging-sw.js の notificationclick が伝えてくる遷移先 URL を受けて
* React Router で navigate する。App.tsx で 1 回のみ呼ぶ (Router の内側必須)。
*/

const parseSameOriginPath = (rawUrl: string): string | null => {
let parsed: URL;
try {
parsed = new URL(rawUrl, window.location.origin);
} catch {
return null;
}
if (parsed.origin !== window.location.origin) return null;
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
};

// FCM SW は root scope 外なので navigation 由来の update check が発火せず、register() の
// byte-diff check も起動 1 回のみ (promise cache のため)。明示 update() が唯一の SW 更新経路。
// PWA セッション中に 1 回だけ呼ぶよう module-level flag で dedupe する。
let fcmSwUpdateTriggered = false;

const promoteFcmSwUpdate = async (sw: ServiceWorkerContainer): Promise<void> => {
if (fcmSwUpdateTriggered) return;
fcmSwUpdateTriggered = true;
try {
const reg = await sw.getRegistration('/firebase-cloud-messaging-push-scope');
await reg?.update();
} catch {
// best effort
}
};

export const useFcmNavigationListener = () => {
const navigate = useNavigate();

useEffect(() => {
// 参照を effect スコープに固定: cleanup 時に navigator.serviceWorker が消えていても
// (テスト環境で差し替えると起こる) removeEventListener で crash させない。
const sw = typeof navigator !== 'undefined' ? navigator.serviceWorker : undefined;

const handler = (event: MessageEvent) => {
if (event.data?.type !== 'FCM_NAVIGATE') return;
const rawUrl = event.data.url;
if (typeof rawUrl !== 'string') return;
const target = parseSameOriginPath(rawUrl);
if (!target) return;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (target === current) return;
navigate(target);
};

sw?.addEventListener('message', handler);
if (sw) void promoteFcmSwUpdate(sw);

return () => {
sw?.removeEventListener('message', handler);
};
}, [navigate]);
};
Loading
Loading