Newer
Older
mobile.raikyakun.app / node / src / app / useWebpush.tsx
nutrition on 24 Jul 2024 11 KB first commit
'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"


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

export default function useWebpush(){
	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(false)

	// アクセスキーが入力されたらサーバへ問い合わせる
	const checkAccessKey = useCallback((newAccessKey: string) => {
		setAccessKey(newAccessKey)
		setIsLoading(true)
		setAccessKeyError(null)
		localStorage.setItem('AccessKey', newAccessKey)
		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)
			console.log('setRemoteHash', data.hash)
			setRemoteHash(data.hash)
			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(() => {
		if ('serviceWorker' in navigator) {
			navigator.serviceWorker.ready
			.then((registration) => {
				// Service Workerの更新をチェック
				registration.update();
				console.log('registration', registration)
				if(!registration.pushManager) {
					console.log('!registration.pushManager')
					setIsSubscribed(false)
					return
				}
				console.log('registration.pushManager', registration.pushManager)
				return registration.pushManager.getSubscription()
				.then(subscription => {
					const accessKey = localStorage.getItem('AccessKey')
					console.log('accessKey', accessKey)	
					if (!subscription) {
						setAvailable(true);
						setIsSubscribed(false);
					}
					// 以下、アクセスキーが登録されている場合
					if(accessKey && accessKey.length === 20) {
						if (!subscription) {
							console.log('OK')
							checkAccessKey(accessKey)
						} else {
							console.log('NG')
							setAvailable(false)
							setIsSubscribed(true)
							const localHash = generateSHA256Hash(subscription.endpoint)
							setLocalHash(localHash)
							checkAccessKey(accessKey)
							.then(remoteHash => {
								console.log('localHash', JSON.stringify(subscription))
								console.log('登録チェック', localHash, remoteHash)
								if(localHash != null && localHash != remoteHash){
									console.log('プッシュ通知登録解除', accessKey)
									// ハッシュを比較して既に登録されている場合は確認ダイアログを表示する
									unsubscribe({isLocalOnly: true, accessKey})
									.then(()=>{
										alert('別の端末で新しく登録されたため登録解除しました')
									})
									.catch(()=>{
										alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')
									})
								}
							})
						}
					}
				})
				.catch(err => {
					console.log('サブスクリプション取得エラー', err)
				})
			})
			.catch(err => setErrorMessage(err.toString()));
	
			navigator.serviceWorker.addEventListener("activate", function (event) {
				console.log("service worker activated");
			});
		} else {
			setErrorMessage('ご利用できない環境です');
		}
		
	}, []);

	const subscribe = useCallback(() => {
		console.log('remoteHash', remoteHash)
		navigator.serviceWorker.ready
		.then(registration => {
			registration.pushManager.subscribe({
				userVisibleOnly: true,
				applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
			})
			.then(async(subscription) => {
				console.log('localHash2', JSON.stringify(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()))
	}, [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()
					console.log('サブスクリプションを解除しました')
					setIsSubscribed(false)
					return true
				} else {
					// ローカル情報のみ削除
					await subscription.unsubscribe()
					console.log('サブスクリプションを解除しました')
					setIsSubscribed(false)
				}
			} catch(err){
				if (err instanceof Error) {
					setErrorMessage('エラー:' + err.message)
				} else {
					setErrorMessage('不明なエラーが発生しました')
				}
				throw err
			}
		} else {
			console.error('サブスクリプションを取得出来ませんでした')
			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('ペーストに失敗しました')
				}
			}
		}
	}
	console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length )
	

	const Subscribe = (
		<>
			{ <>
				<div style={{position: 'relative'}}>
					<FormControl fullWidth  variant="outlined" disabled={!!isSubscribed}>
						<InputLabel htmlFor="url">アクセスキー</InputLabel>
						<OutlinedInput
							id="url"
							value={accessKey}
							startAdornment={
								<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>
							}
							endAdornment={
								<InputAdornment position="end">
									<IconButton edge="end" onClick={()=>{setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}>
											<ClearIcon />
									</IconButton>
								</InputAdornment>
							}
							label="アクセスキー"
							placeholder="(タップしてペースト)"
							onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))}
							readOnly
							disabled={isLoading}
						/>
					</FormControl>
					{isLoading && <CircularProgress sx={{position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, margin: 'auto'}} />}
				</div>
				<FormHelperText>{!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'}</FormHelperText>
				{accessKeyError && <FormHelperText sx={{color: '#ef5350'}}>{accessKeyError}</FormHelperText>}
				<VerticalTabs users={users} setUsers={setUsers} isSubscribed={isSubscribed} />
				<CustomMessageEditor />
			</>}
			{!isSubscribed && <div style={{padding: '1rem'}}>
					<Button fullWidth size="large" variant="contained" disabled={isSubscribed === null || isLoading || users.length < 1} onClick={subscribe}>プッシュ通知登録</Button>
			</div>}
			{isSubscribed && <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 }
}

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)));
}