import React, { useState, useEffect } from 'react'; import { OAuthCallback } from './components/OAuthCallback'; import { AIChat } from './components/AIChat'; import { authService, User } from './services/auth'; import { atprotoOAuthService } from './services/atproto-oauth'; import { appConfig } from './config/app'; import './App.css'; function App() { console.log('APP COMPONENT LOADED - Console working!'); console.log('Current timestamp:', new Date().toISOString()); // Immediately log URL information on every page load console.log('IMMEDIATE URL CHECK:'); console.log('- href:', window.location.href); console.log('- pathname:', window.location.pathname); console.log('- search:', window.location.search); console.log('- hash:', window.location.hash); // Also show URL info via alert if it contains OAuth parameters if (window.location.search.includes('code=') || window.location.search.includes('state=')) { const urlInfo = `OAuth callback detected!\n\nURL: ${window.location.href}\nSearch: ${window.location.search}`; alert(urlInfo); console.log('OAuth callback URL detected!'); } else { // Check if we have stored OAuth info from previous steps const preOAuthUrl = sessionStorage.getItem('pre_oauth_url'); const storedState = sessionStorage.getItem('oauth_state'); const storedCodeVerifier = sessionStorage.getItem('oauth_code_verifier'); console.log('=== OAUTH SESSION STORAGE CHECK ==='); console.log('Pre-OAuth URL:', preOAuthUrl); console.log('Stored state:', storedState); console.log('Stored code verifier:', storedCodeVerifier ? 'Present' : 'Missing'); console.log('=== END SESSION STORAGE CHECK ==='); } const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const [comments, setComments] = useState([]); const [commentText, setCommentText] = useState(''); const [isPosting, setIsPosting] = useState(false); const [error, setError] = useState(null); const [handleInput, setHandleInput] = useState(''); const [userListInput, setUserListInput] = useState(''); const [isPostingUserList, setIsPostingUserList] = useState(false); const [userListRecords, setUserListRecords] = useState([]); const [showJsonFor, setShowJsonFor] = useState(null); const [activeTab, setActiveTab] = useState<'comments' | 'ai-chat'>('comments'); const [aiChatHistory, setAiChatHistory] = useState([]); useEffect(() => { // Setup Jetstream WebSocket for real-time comments (optional) const setupJetstream = () => { try { const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe'); ws.onopen = () => { console.log('Jetstream connected'); ws.send(JSON.stringify({ wantedCollections: [appConfig.collections.comment] })); }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.collection === appConfig.collections.comment && data.commit?.operation === 'create') { console.log('New comment detected via Jetstream:', data); // Optionally reload comments // loadAllComments(window.location.href); } } catch (err) { console.warn('Failed to parse Jetstream message:', err); } }; ws.onerror = (err) => { console.warn('Jetstream error:', err); }; return ws; } catch (err) { console.warn('Failed to setup Jetstream:', err); return null; } }; // Jetstream + Cache example (disabled for now) // const jetstream = setupJetstream(); // キャッシュからコメント読み込み const loadCachedComments = () => { const cached = localStorage.getItem('cached_comments_' + window.location.pathname); if (cached) { const { comments: cachedComments, timestamp } = JSON.parse(cached); // 5分以内のキャッシュなら使用 if (Date.now() - timestamp < 5 * 60 * 1000) { setComments(cachedComments); return true; } } return false; }; // キャッシュがなければ、ATProtoから取得(認証状態に関係なく) if (!loadCachedComments()) { console.log('No cached comments found, loading from ATProto...'); loadAllComments(); // URLフィルタリングを無効にして全コメント表示 } else { console.log('Cached comments loaded successfully'); } // Handle popstate events for mock OAuth flow const handlePopState = () => { const urlParams = new URLSearchParams(window.location.search); const isOAuthCallback = urlParams.has('code') && urlParams.has('state'); if (isOAuthCallback) { // Force re-render to handle OAuth callback window.location.reload(); } }; window.addEventListener('popstate', handlePopState); // Check if this is an OAuth callback const urlParams = new URLSearchParams(window.location.search); const isOAuthCallback = urlParams.has('code') && urlParams.has('state'); if (isOAuthCallback) { return; // Let OAuthCallback component handle this } // Check existing sessions const checkAuth = async () => { // First check OAuth session using official BrowserOAuthClient console.log('Checking OAuth session...'); const oauthResult = await atprotoOAuthService.checkSession(); console.log('OAuth checkSession result:', oauthResult); if (oauthResult) { console.log('OAuth session found:', oauthResult); // Ensure handle is not DID const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle; // Get user profile including avatar const userProfile = await getUserProfile(oauthResult.did, handle); setUser(userProfile); // Load all comments for display (this will be the default view) // Temporarily disable URL filtering to see all comments console.log('OAuth session found, loading all comments...'); loadAllComments(); // Load AI chat history loadAiChatHistory(userProfile.did); // Load user list records if admin if (userProfile.did === appConfig.adminDid) { loadUserListRecords(); } setIsLoading(false); return; } else { console.log('No OAuth session found'); } // Fallback to legacy auth const verifiedUser = await authService.verify(); if (verifiedUser) { setUser(verifiedUser); // Load all comments for display (this will be the default view) // Temporarily disable URL filtering to see all comments console.log('Legacy auth session found, loading all comments...'); loadAllComments(); // Load user list records if admin if (verifiedUser.did === appConfig.adminDid) { loadUserListRecords(); } } setIsLoading(false); // 認証状態に関係なく、コメントを読み込む console.log('No auth session found, loading all comments anyway...'); loadAllComments(); }; checkAuth(); return () => { window.removeEventListener('popstate', handlePopState); }; }, []); const getUserProfile = async (did: string, handle: string): Promise => { try { const agent = atprotoOAuthService.getAgent(); if (agent) { const profile = await agent.getProfile({ actor: handle }); return { did: did, handle: handle, avatar: profile.data.avatar, displayName: profile.data.displayName || handle }; } } catch (error) { console.error('Failed to get user profile:', error); } // Fallback to basic user info return { did: did, handle: handle, avatar: generatePlaceholderAvatar(handle), displayName: handle }; }; const generatePlaceholderAvatar = (handle: string): string => { const initial = handle ? handle.charAt(0).toUpperCase() : 'U'; return `https://via.placeholder.com/48x48/1185fe/ffffff?text=${initial}`; }; const loadAiChatHistory = async (did: string) => { try { console.log('Loading AI chat history for DID:', did); const agent = atprotoOAuthService.getAgent(); if (!agent) { console.log('No agent available'); return; } // Get AI chat records from current user const response = await agent.api.com.atproto.repo.listRecords({ repo: did, collection: appConfig.collections.chat, limit: 100, }); console.log('AI chat history loaded:', response.data); const chatRecords = response.data.records || []; // Filter out old records with invalid AI profile data (temporary fix for migration) const validRecords = chatRecords.filter(record => { if (record.value.answer) { // This is an AI answer - check if it has valid AI profile return record.value.author?.handle && record.value.author?.handle !== 'ai-assistant' && record.value.author?.displayName !== 'AI Assistant'; } return true; // Keep all questions }); console.log(`Filtered ${chatRecords.length} records to ${validRecords.length} valid records`); // Sort by creation time and group question-answer pairs const sortedRecords = validRecords.sort((a, b) => new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime() ); setAiChatHistory(sortedRecords); } catch (err) { console.error('Failed to load AI chat history:', err); setAiChatHistory([]); } }; const loadUserComments = async (did: string) => { try { console.log('Loading comments for DID:', did); const agent = atprotoOAuthService.getAgent(); if (!agent) { console.log('No agent available'); return; } // Get comments from current user const response = await agent.api.com.atproto.repo.listRecords({ repo: did, collection: appConfig.collections.comment, limit: 100, }); console.log('User comments loaded:', response.data); const userComments = response.data.records || []; // Enhance comments with profile information if missing const enhancedComments = await Promise.all( userComments.map(async (record) => { if (!record.value.author?.avatar && record.value.author?.handle) { try { const profile = await agent.getProfile({ actor: record.value.author.handle }); return { ...record, value: { ...record.value, author: { ...record.value.author, avatar: profile.data.avatar, displayName: profile.data.displayName || record.value.author.handle, } } }; } catch (err) { console.warn('Failed to enhance comment with profile:', err); return record; } } return record; }) ); setComments(enhancedComments); } catch (err) { console.error('Failed to load comments:', err); setComments([]); } }; // JSONからユーザーリストを取得 const loadUsersFromRecord = async () => { try { // 管理者のユーザーリストを取得 const adminDid = appConfig.adminDid; console.log('Fetching user list from admin DID:', adminDid); const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(appConfig.collections.user)}&limit=100`); if (!response.ok) { console.warn('Failed to fetch user list from admin, using default users. Status:', response.status); return getDefaultUsers(); } const data = await response.json(); const userRecords = data.records || []; console.log('User records found:', userRecords.length); if (userRecords.length === 0) { console.log('No user records found, using default users'); return getDefaultUsers(); } // レコードからユーザーリストを構築し、プレースホルダーDIDを実際のDIDに解決 const allUsers = []; for (const record of userRecords) { if (record.value.users) { // プレースホルダーDIDを実際のDIDに解決 const resolvedUsers = await Promise.all( record.value.users.map(async (user) => { if (user.did && user.did.includes('-placeholder')) { console.log(`Resolving placeholder DID for ${user.handle}`); try { const profileResponse = await fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(user.handle)}`); if (profileResponse.ok) { const profileData = await profileResponse.json(); if (profileData.did) { console.log(`Resolved ${user.handle}: ${user.did} -> ${profileData.did}`); return { ...user, did: profileData.did }; } } } catch (err) { console.warn(`Failed to resolve DID for ${user.handle}:`, err); } } return user; }) ); allUsers.push(...resolvedUsers); } } console.log('Loaded and resolved users from admin records:', allUsers); return allUsers; } catch (err) { console.warn('Failed to load users from records, using defaults:', err); return getDefaultUsers(); } }; // ユーザーリスト一覧を読み込み const loadUserListRecords = async () => { try { console.log('Loading user list records...'); const adminDid = appConfig.adminDid; const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(appConfig.collections.user)}&limit=100`); if (!response.ok) { console.warn('Failed to fetch user list records'); setUserListRecords([]); return; } const data = await response.json(); const records = data.records || []; // 新しい順にソート const sortedRecords = records.sort((a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() ); console.log(`Loaded ${sortedRecords.length} user list records`); setUserListRecords(sortedRecords); } catch (err) { console.error('Failed to load user list records:', err); setUserListRecords([]); } }; const getDefaultUsers = () => { const defaultUsers = [ // Default admin user { did: appConfig.adminDid, handle: 'syui.ai', pds: 'https://bsky.social' }, ]; // 現在ログインしているユーザーも追加(重複チェック) if (user && user.did && user.handle && !defaultUsers.find(u => u.did === user.did)) { defaultUsers.push({ did: user.did, handle: user.handle, pds: user.handle.endsWith('.syu.is') ? 'https://syu.is' : 'https://bsky.social' }); } console.log('Default users list (including current user):', defaultUsers); return defaultUsers; }; // 新しい関数: 全ユーザーからコメントを収集 const loadAllComments = async (pageUrl?: string) => { try { console.log('Loading comments from all users...'); console.log('Page URL filter:', pageUrl); // ユーザーリストを動的に取得 const knownUsers = await loadUsersFromRecord(); console.log('Known users for comment fetching:', knownUsers); const allComments = []; // 各ユーザーからコメントを収集 for (const user of knownUsers) { try { console.log(`Fetching comments from user: ${user.handle} (${user.did}) at ${user.pds}`); // Public API使用(認証不要) const response = await fetch(`${user.pds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(user.did)}&collection=${encodeURIComponent(appConfig.collections.comment)}&limit=100`); if (!response.ok) { console.warn(`Failed to fetch from ${user.handle} (${response.status}): ${response.statusText}`); continue; } const data = await response.json(); const userRecords = data.records || []; console.log(`Found ${userRecords.length} comment records from ${user.handle}`); // Flatten comments from new array format const userComments = []; for (const record of userRecords) { if (record.value.comments && Array.isArray(record.value.comments)) { // New format: array of comments for (const comment of record.value.comments) { userComments.push({ ...record, value: comment, originalRecord: record // Keep reference to original record }); } } else if (record.value.text) { // Old format: single comment userComments.push(record); } } console.log(`Flattened to ${userComments.length} individual comments from ${user.handle}`); // ページURLでフィルタリング(指定された場合) const filteredComments = pageUrl ? userComments.filter(record => record.value.url === pageUrl) : userComments; console.log(`After URL filtering (${pageUrl}): ${filteredComments.length} comments from ${user.handle}`); console.log('All comments from this user:', userComments.map(r => ({ url: r.value.url, text: r.value.text }))); allComments.push(...filteredComments); } catch (err) { console.warn(`Failed to load comments from ${user.handle}:`, err); } } // 時間順にソート(新しい順) const sortedComments = allComments.sort((a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() ); // プロフィール情報で拡張(認証なしでも取得可能) const enhancedComments = await Promise.all( sortedComments.map(async (record) => { if (!record.value.author?.avatar && record.value.author?.handle) { try { // Public API でプロフィール取得 const profileResponse = await fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.handle)}`); if (profileResponse.ok) { const profileData = await profileResponse.json(); return { ...record, value: { ...record.value, author: { ...record.value.author, avatar: profileData.avatar, displayName: profileData.displayName || record.value.author.handle, } } }; } } catch (err) { console.warn('Failed to enhance comment with profile:', err); } } return record; }) ); console.log(`Loaded ${enhancedComments.length} comments from all users`); // デバッグ情報を追加 console.log('Final enhanced comments:', enhancedComments); console.log('Known users used:', knownUsers); setComments(enhancedComments); console.log('Comments state updated with', enhancedComments.length, 'comments'); // キャッシュに保存(5分間有効) if (pageUrl) { const cacheKey = 'cached_comments_' + new URL(pageUrl).pathname; const cacheData = { comments: enhancedComments, timestamp: Date.now() }; localStorage.setItem(cacheKey, JSON.stringify(cacheData)); } } catch (err) { console.error('Failed to load all comments:', err); setComments([]); } }; const handlePostComment = async () => { if (!user || !commentText.trim()) { return; } setIsPosting(true); setError(null); try { const agent = atprotoOAuthService.getAgent(); if (!agent) { throw new Error('No agent available'); } // Create comment record with post-specific rkey const now = new Date(); // Use post rkey if on post page, otherwise use timestamp-based rkey const rkey = appConfig.rkey || now.toISOString().replace(/[:.]/g, '-'); const newComment = { text: commentText, url: window.location.href, createdAt: now.toISOString(), author: { did: user.did, handle: user.handle, avatar: user.avatar, displayName: user.displayName || user.handle, }, }; // Check if record with this rkey already exists let existingComments = []; try { const existingResponse = await agent.api.com.atproto.repo.getRecord({ repo: user.did, collection: appConfig.collections.comment, rkey: rkey, }); // Handle both old single comment format and new array format if (existingResponse.data.value.comments) { // New format: array of comments existingComments = existingResponse.data.value.comments; } else if (existingResponse.data.value.text) { // Old format: single comment, convert to array existingComments = [{ text: existingResponse.data.value.text, url: existingResponse.data.value.url, createdAt: existingResponse.data.value.createdAt, author: existingResponse.data.value.author, }]; } } catch (err) { // Record doesn't exist yet, that's fine console.log('No existing record found, creating new one'); } // Add new comment to the array existingComments.push(newComment); // Create the record with comments array const record = { $type: appConfig.collections.comment, comments: existingComments, url: window.location.href, createdAt: now.toISOString(), // Latest update time }; // Post to ATProto with rkey const response = await agent.api.com.atproto.repo.putRecord({ repo: user.did, collection: appConfig.collections.comment, rkey: rkey, record: record, }); console.log('Comment posted:', response); // Clear form and reload all comments setCommentText(''); await loadAllComments(window.location.href); } catch (err: any) { console.error('Failed to post comment:', err); setError('コメントの投稿に失敗しました: ' + err.message); } finally { setIsPosting(false); } }; const handleDeleteComment = async (uri: string) => { if (!user) { alert('ログインが必要です'); return; } if (!confirm('このコメントを削除しますか?')) { return; } try { const agent = atprotoOAuthService.getAgent(); if (!agent) { throw new Error('No agent available'); } // Extract rkey from URI: at://did:plc:xxx/ai.syui.log/rkey const uriParts = uri.split('/'); const rkey = uriParts[uriParts.length - 1]; console.log('Deleting comment with rkey:', rkey); // Delete the record await agent.api.com.atproto.repo.deleteRecord({ repo: user.did, collection: appConfig.collections.comment, rkey: rkey, }); console.log('Comment deleted successfully'); // Reload all comments to reflect the deletion await loadAllComments(window.location.href); } catch (err: any) { console.error('Failed to delete comment:', err); alert('コメントの削除に失敗しました: ' + err.message); } }; const handleLogout = async () => { // Logout from both services await authService.logout(); atprotoOAuthService.logout(); setUser(null); setComments([]); }; // 管理者チェック const isAdmin = (user: User | null): boolean => { return user?.did === appConfig.adminDid; }; // ユーザーリスト投稿 const handlePostUserList = async () => { if (!user || !userListInput.trim()) { return; } if (!isAdmin(user)) { alert('管理者のみがユーザーリストを更新できます'); return; } setIsPostingUserList(true); setError(null); try { const agent = atprotoOAuthService.getAgent(); if (!agent) { throw new Error('No agent available'); } // ユーザーリストをパース const userHandles = userListInput .split(',') .map(handle => handle.trim()) .filter(handle => handle.length > 0); // ユーザーリストを各PDS用に分類し、実際のDIDを解決 const users = await Promise.all(userHandles.map(async (handle) => { const pds = handle.endsWith('.syu.is') ? 'https://syu.is' : 'https://bsky.social'; // 実際のDIDを解決 let resolvedDid = `did:plc:${handle.replace(/\./g, '-')}-placeholder`; // フォールバック try { // Public APIでプロフィールを取得してDIDを解決 const profileResponse = await fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`); if (profileResponse.ok) { const profileData = await profileResponse.json(); if (profileData.did) { resolvedDid = profileData.did; console.log(`Resolved ${handle} -> ${resolvedDid}`); } } } catch (err) { console.warn(`Failed to resolve DID for ${handle}:`, err); } return { handle: handle, pds: pds, did: resolvedDid }; })); // Create user list record with ISO datetime rkey const now = new Date(); const rkey = now.toISOString().replace(/[:.]/g, '-'); const record = { $type: appConfig.collections.user, users: users, createdAt: now.toISOString(), updatedBy: { did: user.did, handle: user.handle, }, }; // Post to ATProto with rkey const response = await agent.api.com.atproto.repo.putRecord({ repo: user.did, collection: appConfig.collections.user, rkey: rkey, record: record, }); console.log('User list posted:', response); // Clear form and reload user list records setUserListInput(''); loadUserListRecords(); alert('ユーザーリストが更新されました'); } catch (err: any) { console.error('Failed to post user list:', err); setError('ユーザーリストの投稿に失敗しました: ' + err.message); } finally { setIsPostingUserList(false); } }; // ユーザーリスト削除 const handleDeleteUserList = async (uri: string) => { if (!user || !isAdmin(user)) { alert('管理者のみがユーザーリストを削除できます'); return; } if (!confirm('このユーザーリストを削除しますか?')) { return; } try { const agent = atprotoOAuthService.getAgent(); if (!agent) { throw new Error('No agent available'); } // Extract rkey from URI const uriParts = uri.split('/'); const rkey = uriParts[uriParts.length - 1]; console.log('Deleting user list with rkey:', rkey); // Delete the record await agent.api.com.atproto.repo.deleteRecord({ repo: user.did, collection: appConfig.collections.user, rkey: rkey, }); console.log('User list deleted successfully'); loadUserListRecords(); alert('ユーザーリストが削除されました'); } catch (err: any) { console.error('Failed to delete user list:', err); alert('ユーザーリストの削除に失敗しました: ' + err.message); } }; // JSON表示のトグル const toggleJsonDisplay = (uri: string) => { if (showJsonFor === uri) { setShowJsonFor(null); } else { setShowJsonFor(uri); } }; // OAuth実行関数 const executeOAuth = async () => { if (!handleInput.trim()) { alert('Please enter your Bluesky handle first'); return; } try { await atprotoOAuthService.initiateOAuthFlow(handleInput); } catch (err) { console.error('OAuth failed:', err); alert('認証の開始に失敗しました。再度お試しください。'); } }; // ユーザーハンドルからプロフィールURLを生成 const generateProfileUrl = (handle: string, did: string): string => { if (handle.endsWith('.syu.is')) { return `https://web.syu.is/profile/${did}`; } else { return `https://bsky.app/profile/${did}`; } }; // Rkey-based comment filtering // If on post page (/posts/xxx.html), only show comments with rkey=xxx const shouldShowComment = (record: any): boolean => { // If not on a post page, show all comments if (!appConfig.rkey) { return true; } // Extract rkey from comment URI: at://did:plc:xxx/collection/rkey const uriParts = record.uri.split('/'); const commentRkey = uriParts[uriParts.length - 1]; // Show comment only if rkey matches current post return commentRkey === appConfig.rkey; }; // OAuth callback is now handled by React Router in main.tsx console.log('=== APP.TSX URL CHECK ==='); console.log('Full URL:', window.location.href); console.log('Pathname:', window.location.pathname); console.log('Search params:', window.location.search); console.log('=== END URL CHECK ==='); return (
{/* Authentication Section */} {!user ? (
setHandleInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); executeOAuth(); } }} />
) : (
User Avatar

{user.displayName || user.handle}

@{user.handle}

{user.did}

{/* Admin Section - User Management */} {isAdmin(user) && (

管理者機能 - ユーザーリスト管理

{/* User List Form */}