Newer
Older
mobile.raikyakun.app / node / src / app / useWebpush.tsx
nutrition 14 hours ago 16 KB 利便性向上
'use client'
import { useEffect, useMemo, useState, useCallback } from 'react'
import webpush from 'web-push'
import { Button, FormHelperText, Alert, FormControl, InputLabel, OutlinedInput, InputAdornment, IconButton, Tooltip, ClickAwayListener, Tabs, CircularProgress } from '@mui/material'
import { ContentPasteGo as ContentPasteGoIcon } from '@mui/icons-material'
import ClearIcon from '@mui/icons-material/Clear'
const vapidPublicKey = 'BCog1diPhg4bePvGy-7k75yfmKqDTG9Z90aA6h9-3AAZ9ydYtF_HUYkoOyWF4dUi2-lb1fWbkojhaaS8P2UKaLA'
import axios, { AxiosError } from 'axios'
import { VerticalTabs, CustomMessageEditor } from '@/components'
import { User } from '@/types'
import jsSHA from "jssha"
import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios'

function generateSHA256Hash(data: string): string {
  const shaObj = new jsSHA("SHA-256", "TEXT");
  shaObj.update(data);
  return shaObj.getHash("HEX");
}

export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){
	const [available, setAvailable] = useState(false)
	const [isSubscribed, setIsSubscribed] = useState<boolean | null>(null)
	const [errorMessage, setErrorMessage] = useState('')
	const [tooltipMessage, setTooltipMessagep] = useState<null | string>(null)
	const [accessKey, setAccessKey] = useState("")
	const [accessKeyError, setAccessKeyError] = useState<null | string>(null)
	const [users, setUsers] = useState<User[]>([])
	const [localHash, setLocalHash] = useState<null | string>(null)
	const [remoteHash, setRemoteHash] = useState<null | string>(null)
	const [isLoading, setIsLoading] = useState(true)
	const [pushSupported, setPushSupported] = useState<boolean | null>(null)
	const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null })

	// アクセスキーが入力されたらサーバへ問い合わせる
	const checkAccessKey = useCallback((newAccessKey: string) => {
		setAccessKey(newAccessKey)
		setIsLoading(true)
		setAccessKeyError(null)
		return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey)
		.then(({data}) => {
			setUsers(data.users)
			localStorage.setItem('Users', JSON.stringify(data.users))
			setIsLoading(false)
			setRemoteHash(data.hash)
			fetch('/accessKey', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({ accessKey: newAccessKey }),
			})
			return data.hash
		})
		.catch(err => {
			setUsers([])
			console.error(err)
			if (axios.isAxiosError(err)) {
				const serverError = err as AxiosError<{error: string}>
				if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error)
				else setAccessKeyError('接続に失敗しました')
			} else setAccessKeyError('不明なエラーが発生しました')
			setIsLoading(false)
			return null
		})
	}, [])

	// 初回読み込み時
	useEffect(() => {
		const saveKey = (key: string) => fetch('/accessKey', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ accessKey: key }),
		})

		const init = async () => {
			// 1. 現在のCookieキーを取得
			let cookieKey: string | null = null
			try {
				const res = await fetch('/accessKey')
				if (res.ok) {
					const data: { accessKey: string | null } = await res.json()
					cookieKey = data.accessKey
				}
			} catch (e) {
				console.error('GET /accessKey failed', e)
			}

			// 2. URLハッシュのキーを取得し、即座に消去
			const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/)
			const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null
			if (hashKey) history.replaceState(null, '', location.pathname + location.search)

			// 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合)
			let migratedKey: string | null = null
			if (!cookieKey && !hashKey) {
				const lsKey = localStorage.getItem('AccessKey')
				if (lsKey && lsKey.length === 20) {
					await saveKey(lsKey)
					localStorage.removeItem('AccessKey')
					migratedKey = lsKey
				}
			}

			// WebViewの場合
			if (!('serviceWorker' in navigator)) {
				const resolvedKey = hashKey ?? cookieKey ?? migratedKey
				if (hashKey && hashKey !== cookieKey) await saveKey(hashKey)
				if (resolvedKey) setAccessKey(resolvedKey)
				setIsLoading(true)
				checkNotificationStatus()
				.then(res => {
					setState(res)
					if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') {
						setIsSubscribed(false)
					}
					setIsLoading(false)
				})
				.catch(e => {
					console.error(e)
					setErrorMessage('ご利用できない環境です')
					setIsLoading(false)
				})
				return
			}

			// 4. SW初期化
			let registration: ServiceWorkerRegistration
			try {
				registration = await navigator.serviceWorker.ready
			} catch (err) {
				setErrorMessage(String(err))
				return
			}
			registration.update()

			if (!registration.pushManager) {
				setIsSubscribed(false)
				setPushSupported(false)
				const resolvedKey = hashKey ?? cookieKey ?? migratedKey
				if (hashKey) await saveKey(hashKey)
				if (resolvedKey) {
					setAccessKey(resolvedKey)
					checkAccessKey(resolvedKey)
				} else {
					setIsLoading(false)
				}
				return
			}
			setPushSupported(true)

			let subscription: PushSubscription | null
			try {
				subscription = await registration.pushManager.getSubscription()
			} catch (err) {
				console.error('サブスクリプション取得エラー', err)
				return
			}

			// 5. キー変更 + サブスク登録済み → ユーザーに確認
			const isKeyChange = hashKey !== null && hashKey !== cookieKey
			let keyChangeCancelled = false
			if (isKeyChange && subscription && cookieKey) {
				if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) {
					// 確認: フル解除 → 新キーで継続
					try {
						await unsubscribe({ isLocalOnly: false, accessKey: cookieKey })
					} catch {
						setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。')
						return
					}
					await saveKey(hashKey)
					setAccessKey(hashKey)
					setUsers([])
					checkAccessKey(hashKey)
					return
				}
				// キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま)
				keyChangeCancelled = true
			}

			// 6. キーの確定
			let resolvedKey: string | null
			if (keyChangeCancelled) {
				resolvedKey = cookieKey
			} else if (hashKey) {
				// キー変更でサブスクなし、または同一キーの再アクセス
				await saveKey(hashKey)
				resolvedKey = hashKey
			} else {
				resolvedKey = cookieKey ?? migratedKey
			}

			if (resolvedKey) setAccessKey(resolvedKey)

			if (!subscription) {
				setAvailable(true)
				setIsSubscribed(false)
				if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey)
				else setIsLoading(false)
			} else {
				setAvailable(false)
				setIsSubscribed(true)
				if (resolvedKey && resolvedKey.length === 20) {
					const localHash = generateSHA256Hash(subscription.endpoint)
					setLocalHash(localHash)
					checkAccessKey(resolvedKey).then(remoteHash => {
						if (localHash !== null && localHash !== remoteHash) {
							// ハッシュを比較して別端末で登録済みの場合はローカルのみ解除
							unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! })
							.then(() => alert('別の端末で新しく登録されたため登録解除しました'))
							.catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました'))
						}
					})
				} else {
					setIsLoading(false)
				}
			}
		}

		const handleHashChange = () => {
			if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init()
		}
		window.addEventListener('hashchange', handleHashChange)
		init()
		return () => window.removeEventListener('hashchange', handleHashChange)
	}, [])



	const subscribe = useCallback(() => {
		if ('serviceWorker' in navigator) {
			navigator.serviceWorker.ready
			.then(registration => {
				registration.pushManager.subscribe({
					userVisibleOnly: true,
					applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
				})
				.then(async(subscription) => {
						const latestRemoteHash = await checkAccessKey(accessKey)
					if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return
					setIsSubscribed(true)
					
					axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => {
						const { id, isStealth } = item
						if(id) acc[id] = isStealth
						return acc
					}, {} as {[key: string]: boolean})})
					.catch((err: AxiosError<{error: string}>) => {
						// エラーの処理
						if (err.response?.data?.error) {
							// サーバーからの応答がある場合
							setErrorMessage(err.response.data.error)
						} else {
							// リクエストの設定中に何かが起こった場合
							setErrorMessage('エラーが発生しました:' + err.message)
						}
					})
				})
				.catch(err => setErrorMessage(err.toString()))
			})
			.catch(err => setErrorMessage(err.toString()))
		} else {
			// WebViewの場合
			setIsLoading(true)
			requestNotificationPermission()
			.then(res => {
				setState(res)
				setIsLoading(false)
			})
			.catch((e) => {
				console.error(e)
				setErrorMessage('ご利用できない環境です')
			})
		}
	}, [accessKey, users, remoteHash])

	const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => {
		const registration = await navigator.serviceWorker.ready
		const subscription = await registration.pushManager.getSubscription()
		if (subscription) {
			try {
				if(opts && !opts.isLocalOnly){
					// サーバに登録している情報を削除する
					const localHash = generateSHA256Hash(subscription.endpoint)
					if(!opts.accessKey) throw new Error('アクセスキーがありません')
					
					await axios.patch('/api/mobile/'+opts.accessKey, {subscription: null, hash: localHash})
					.catch((err: AxiosError<{error: string}>) => {
						// エラーの処理
						if (err.response?.data?.error) {
							// サーバーからの応答がある場合
							throw new Error(err.response.data.error)
						} else {
							// リクエストの設定中に何かが起こった場合
							throw new Error(err.message)
						}
					})
					setRemoteHash(null)
					await subscription.unsubscribe()
					setIsSubscribed(false)
					return true
				} else {
					// ローカル情報のみ削除
					await subscription.unsubscribe()
					setIsSubscribed(false)
				}
			} catch(err){
				if (err instanceof Error) {
					setErrorMessage('エラー:' + err.message)
				} else {
					setErrorMessage('不明なエラーが発生しました')
				}
				throw err
			}
		} else {
			throw new Error('サブスクリプションを取得出来ませんでした')
		}
		return false
	}, [])

	const pasteFromClipboard = async() => {
		try {
			const text = await navigator.clipboard.readText()
			setTooltipMessagep('ペーストしました')
			checkAccessKey(text)
			// ここで取得したテキストを処理する
		} catch (error) {
			if (error instanceof Error) {
				if (error.name === 'NotAllowedError') {
					setTooltipMessagep('アクセスが許可されていません')
				} else {
					setTooltipMessagep('ペーストに失敗しました')
				}
			}
		}
	}
		// ボタンを押した時
	const handleClick = () => {
		requestNotificationPermission()
			.then((res) => {
				setState(res)
				// status が requested → 直後に OS がトークンを返すと
				// ScriptBridge が onNotificationUpdate() を再送してくるので
				// その都度 setState が呼ばれる
			})
			.catch((e) => console.error(e));
	}

	const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false

	const Subscribe = (
		<>
			{ <>
				<div style={{position: 'relative', display: 'flex', justifyContent: 'center'}}>
					<FormControl variant="outlined" disabled={!!isSubscribed} sx={{maxWidth: '94%'}} error={!!accessKeyError}>
						<InputLabel htmlFor="url">アクセスキー</InputLabel>
						<OutlinedInput
							id="url"
							value={accessKey}
							startAdornment={os === 'Other' || isStandalone
								? <ClickAwayListener onClickAway={()=>setTooltipMessagep(null)}>
									<InputAdornment position="start">
										<Tooltip
											PopperProps={{disablePortal: true}} onClose={()=>setTooltipMessagep(null)} open={!!tooltipMessage}
											disableFocusListener disableHoverListener disableTouchListener
											placement="top" title={tooltipMessage}
										>
											<IconButton edge="start" onClick={pasteFromClipboard} disabled={!!isSubscribed || isLoading}>
												<ContentPasteGoIcon />
											</IconButton>
										</Tooltip>
									</InputAdornment>
								</ClickAwayListener>
								: undefined
							}
							endAdornment={os === 'Other' || isStandalone
								? <InputAdornment position="end">
									<IconButton edge="end" onClick={()=>{setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}>
										<ClearIcon />
									</IconButton>
								</InputAdornment>
								: undefined
							}
							label="アクセスキー"
							placeholder="(タップしてペースト)"
							onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))}
							readOnly
							disabled={isLoading}
							inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}}
						/>
					</FormControl>
					{isLoading && <CircularProgress sx={{position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, margin: 'auto'}} />}
				</div>
				<FormHelperText sx={{textAlign: 'center'}}>{!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'}</FormHelperText>
				{accessKeyError && <Alert severity="error" sx={{mt: 1}}>{accessKeyError}</Alert>}
				{(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && <VerticalTabs users={users} setUsers={setUsers} isSubscribed={isSubscribed} />}
				{(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && <CustomMessageEditor />}
			</>}
			{!isSubscribed && <div style={{padding: '1rem'}}>
					{pushSupported === false && os === 'iOS' && browser !== 'Safari' && <>
						<FormHelperText sx={{mb: 1, color: 'warning.main'}}>プッシュ通知にはSafariで開く必要があります</FormHelperText>
						<Button fullWidth variant="outlined" component="a" href={`x-safari-${process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app'}/${accessKey ? `#accessKey=${accessKey}` : ''}`}>Safariで開く</Button>
					</>}
					{pushSupported === false && os !== 'iOS' && <FormHelperText sx={{mb: 1, color: 'warning.main'}}>このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。</FormHelperText>}
					{registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && <FormHelperText sx={{mb: 1, color: 'warning.main'}}>プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください</FormHelperText>}
					{registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && <Button fullWidth size="large" variant="contained" disabled={isSubscribed === null || isLoading || users.length < 1} onClick={subscribe}>プッシュ通知登録</Button>}
					{registerMode === 'install-required' && <FormHelperText sx={{textAlign: 'center', color: 'warning.main'}}>プッシュ通知を受け取るにはWebアプリとしてインストールしてください</FormHelperText>}
			</div>}
			{isSubscribed && registerMode !== 'hidden' && <div style={{padding: '1rem'}}>
				<Button fullWidth size="large" variant="outlined" color="error" disabled={isSubscribed === null} onClick={()=>unsubscribe({isLocalOnly: false, accessKey})}>プッシュ通知登録解除</Button>
			</div>}
		</>
	)

	return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading }
}

function urlBase64ToUint8Array(base64String: string) {
	const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
	const base64 = (base64String + padding)
		.replace(/\-/g, '+')
		.replace(/_/g, '/')
	const rawData = window.atob(base64);
	return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}