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, getCollectionNames } from './config/app'; import { getProfileForUser, detectPdsFromHandle, getApiUrlForUser, verifyPdsDetection, getNetworkConfigFromPdsEndpoint, getNetworkConfig } from './utils/pds-detection'; import { isValidDid } from './utils/validation'; import './App.css'; function App() { // Handle OAuth callback detection 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); } 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' | 'lang-en' | 'ai-comment'>('comments'); const [aiChatHistory, setAiChatHistory] = useState([]); const [langEnRecords, setLangEnRecords] = useState([]); const [aiCommentRecords, setAiCommentRecords] = useState([]); const [aiProfile, setAiProfile] = useState(null); const [adminDid, setAdminDid] = useState(null); const [aiDid, setAiDid] = useState(null); // ハンドルからDIDを解決する関数 const resolveHandleToDid = async (handle: string): Promise => { try { const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(handle)); return profile?.did || null; } catch { return null; } }; useEffect(() => { // 管理者とAIのDIDを解決 const resolveAdminAndAiDids = async () => { const [resolvedAdminDid, resolvedAiDid] = await Promise.all([ resolveHandleToDid(appConfig.adminHandle), resolveHandleToDid(appConfig.aiHandle) ]); setAdminDid(resolvedAdminDid || appConfig.adminDid); setAiDid(resolvedAiDid || appConfig.aiDid); }; resolveAdminAndAiDids(); // Setup Jetstream WebSocket for real-time comments (optional) const setupJetstream = () => { try { const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe'); const collections = getCollectionNames(appConfig.collections.base); ws.onopen = () => { ws.send(JSON.stringify({ wantedCollections: [collections.comment] })); }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.collection === collections.comment && data.commit?.operation === 'create') { // Optionally reload comments // loadAllComments(window.location.href); } } catch (err) { // Ignore parsing errors } }; ws.onerror = (err) => { // Ignore Jetstream errors }; return ws; } catch (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; }; // DID解決が完了してからコメントとチャット履歴を読み込む const loadDataAfterDidResolution = () => { // キャッシュがなければ、ATProtoから取得(認証状態に関係なく) if (!loadCachedComments()) { loadAllComments(); // URLフィルタリングを無効にして全コメント表示 } // Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示) loadAiChatHistory(); // Load AI generated content (lang:en and AI comments) loadAIGeneratedContent(); }; // Wait for DID resolution before loading data if (adminDid && aiDid) { loadDataAfterDidResolution(); } else { // Wait a bit and try again setTimeout(() => { if (adminDid && aiDid) { loadDataAfterDidResolution(); } }, 1000); } // Load AI profile from handle const loadAiProfile = async () => { try { // Use VITE_AI_HANDLE to detect PDS and get profile const handle = appConfig.aiHandle; if (!handle) { throw new Error('No AI handle configured'); } // Detect PDS: Use VITE_ATPROTO_PDS if handle matches admin/ai handles let pds; if (handle === appConfig.adminHandle || handle === appConfig.aiHandle) { // Use configured PDS for admin/ai handles pds = appConfig.atprotoPds || 'syu.is'; } else { // Use handle-based detection for other handles pds = detectPdsFromHandle(handle); } const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`); const apiEndpoint = config.bskyApi; // Get profile from appropriate bsky API const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`); if (profileResponse.ok) { const profileData = await profileResponse.json(); setAiProfile({ did: profileData.did || appConfig.aiDid, handle: profileData.handle || handle, displayName: profileData.displayName || appConfig.aiDisplayName || 'ai', avatar: profileData.avatar || generatePlaceholderAvatar(handle), description: profileData.description || appConfig.aiDescription || '' }); } else { // Fallback to config values setAiProfile({ did: appConfig.aiDid, handle: handle, displayName: appConfig.aiDisplayName || 'ai', avatar: generatePlaceholderAvatar(handle), description: appConfig.aiDescription || '' }); } } catch (err) { // Failed to load AI profile // Fallback to config values setAiProfile({ did: appConfig.aiDid, handle: appConfig.aiHandle, displayName: appConfig.aiDisplayName || 'ai', avatar: generatePlaceholderAvatar(appConfig.aiHandle || 'ai'), description: appConfig.aiDescription || '' }); } }; loadAiProfile(); // 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 const oauthResult = await atprotoOAuthService.checkSession(); if (oauthResult) { // Ensure handle is not DID const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle; // Note: appConfig.allowedHandles is used for PDS detection, not access control // 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 loadAllComments(); // Load AI chat history loadAiChatHistory(); // Load user list records if admin if (userProfile.did === adminDid) { loadUserListRecords(); } setIsLoading(false); return; } // Fallback to legacy auth const verifiedUser = await authService.verify(); if (verifiedUser) { // Check if handle is allowed if (appConfig.allowedHandles.length > 0 && !appConfig.allowedHandles.includes(verifiedUser.handle)) { // Handle not in allowed list setError(`Access denied: ${verifiedUser.handle} is not authorized for this application.`); setIsLoading(false); return; } setUser(verifiedUser); // Load all comments for display (this will be the default view) // Temporarily disable URL filtering to see all comments loadAllComments(); // Load user list records if admin if (verifiedUser.did === adminDid) { loadUserListRecords(); } } setIsLoading(false); // 認証状態に関係なく、コメントを読み込む loadAllComments(); }; checkAuth(); // Load AI generated content (public) loadAIGeneratedContent(); return () => { window.removeEventListener('popstate', handlePopState); }; }, []); // DID解決完了時にデータを再読み込み useEffect(() => { if (adminDid && aiDid) { loadAllComments(); loadAiChatHistory(); loadAIGeneratedContent(); } }, [adminDid, aiDid]); 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) { // Failed to get user profile } // 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'; const svg = ` ${initial} `; return `data:image/svg+xml;base64,${btoa(svg)}`; }; const loadAiChatHistory = async () => { try { // Load all chat records from users in admin's user list const currentAdminDid = adminDid || appConfig.adminDid; // Don't proceed if we don't have a valid DID if (!currentAdminDid || !isValidDid(currentAdminDid)) { return; } // Resolve admin's actual PDS from their DID let adminPdsEndpoint; try { const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid)); const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); adminPdsEndpoint = config.pdsApi; } catch { // Fallback to configured PDS const adminConfig = getNetworkConfig(appConfig.atprotoPds); adminPdsEndpoint = adminConfig.pdsApi; } const collections = getCollectionNames(appConfig.collections.base); const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`); if (!userListResponse.ok) { setAiChatHistory([]); return; } const userListData = await userListResponse.json(); const userRecords = userListData.records || []; // Extract unique DIDs from user records (including admin DID for their own chats) const allUserDids = []; userRecords.forEach(record => { if (record.value.users && Array.isArray(record.value.users)) { record.value.users.forEach(user => { if (user.did) { allUserDids.push(user.did); } }); } }); // Always include admin DID to check admin's own chats allUserDids.push(currentAdminDid); const userDids = [...new Set(allUserDids)]; // Load chat records from all registered users (including admin) using per-user PDS detection const allChatRecords = []; for (const userDid of userDids) { try { // Use per-user PDS detection for each user's chat records let userPdsEndpoint; try { // Validate DID format before making API calls if (!userDid || !userDid.startsWith('did:')) { continue; } const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(userDid)); const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); userPdsEndpoint = config.pdsApi; } catch { userPdsEndpoint = atprotoApi; // Fallback } const chatResponse = await fetch(`${userPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(userDid)}&collection=${encodeURIComponent(collections.chat)}&limit=100`); if (chatResponse.ok) { const chatData = await chatResponse.json(); const records = chatData.records || []; allChatRecords.push(...records); } else if (chatResponse.status === 400) { // Skip 400 errors (repo not found, etc) continue; } } catch (err) { continue; } } // Filter for page-specific content if on a post page let filteredRecords = allChatRecords; if (appConfig.rkey) { // On post page: show only chats for this specific post filteredRecords = allChatRecords.filter(record => { const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : ''; return recordRkey === appConfig.rkey; }); } else { // On top page: show latest 3 records from all pages filteredRecords = allChatRecords.slice(0, 3); } // Filter out old records with invalid AI profile data (temporary fix for migration) const validRecords = filteredRecords.filter(record => { if (record.value.type === '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 }); // Sort by creation time const sortedRecords = validRecords.sort((a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() ); setAiChatHistory(sortedRecords); } catch (err) { setAiChatHistory([]); } }; // Load AI generated content from admin DID const loadAIGeneratedContent = async () => { try { const currentAdminDid = adminDid || appConfig.adminDid; // Don't proceed if we don't have a valid DID if (!currentAdminDid || !isValidDid(currentAdminDid)) { return; } // Resolve admin's actual PDS from their DID let atprotoApi; try { const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid)); const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); atprotoApi = config.pdsApi; } catch { // Fallback to configured PDS const adminConfig = getNetworkConfig(appConfig.atprotoPds); atprotoApi = adminConfig.pdsApi; } const collections = getCollectionNames(appConfig.collections.base); // Load lang:en records const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`); if (langResponse.ok) { const langData = await langResponse.json(); const langRecords = langData.records || []; // Filter by current page rkey if on post page const filteredLangRecords = appConfig.rkey ? langRecords.filter(record => { // Compare rkey only (last part of path) const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : ''; return recordRkey === appConfig.rkey; }) : langRecords.slice(0, 3); // Top page: latest 3 setLangEnRecords(filteredLangRecords); } // Load AI comment records from admin account (not AI account) if (!currentAdminDid) { console.warn('No Admin DID available, skipping AI comment loading'); setAiCommentRecords([]); return; } const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`); if (commentResponse.ok) { const commentData = await commentResponse.json(); const commentRecords = commentData.records || []; // Filter by current page rkey if on post page const filteredCommentRecords = appConfig.rkey ? commentRecords.filter(record => { // Compare rkey only (last part of path) const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : ''; return recordRkey === appConfig.rkey; }) : commentRecords.slice(0, 3); // Top page: latest 3 setAiCommentRecords(filteredCommentRecords); } } catch (err) { // Ignore errors } }; const loadUserComments = async (did: string) => { try { const agent = atprotoOAuthService.getAgent(); if (!agent) { return; } // Get comments from current user const response = await agent.api.com.atproto.repo.listRecords({ repo: did, collection: getCollectionNames(appConfig.collections.base).comment, limit: 100, }); const userComments = response.data.records || []; // Enhance comments with fresh profile information const enhancedComments = await Promise.all( userComments.map(async (record) => { if (record.value.author?.handle) { try { // Use existing PDS detection logic const handle = record.value.author.handle; const pds = detectPdsFromHandle(handle); const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`); const apiEndpoint = config.bskyApi; const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`); if (profileResponse.ok) { const profileData = await profileResponse.json(); // Determine correct web URL based on avatar source let webUrl = config.webUrl; // Default from handle-based detection if (profileData.avatar && profileData.avatar.includes('cdn.bsky.app')) { webUrl = 'https://bsky.app'; // Override to Bluesky if avatar is from Bluesky } return { ...record, value: { ...record.value, author: { ...record.value.author, avatar: profileData.avatar, displayName: profileData.displayName || handle, _pdsEndpoint: `https://${pds}`, // Store PDS info for later use _webUrl: webUrl, // Store corrected web URL for profile links } } }; } else { // If profile fetch fails, still add PDS info for links return { ...record, value: { ...record.value, author: { ...record.value.author, _pdsEndpoint: `https://${pds}`, _webUrl: config.webUrl, } } }; } } catch (err) { // Ignore enhancement errors, use existing data } } return record; }) ); setComments(enhancedComments); } catch (err) { // Ignore load errors setComments([]); } }; // JSONからユーザーリストを取得 const loadUsersFromRecord = async () => { try { // 管理者のユーザーリストを取得 using proper PDS detection const currentAdminDid = adminDid || appConfig.adminDid; // Use per-user PDS detection for admin's records let adminPdsEndpoint; try { const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid)); const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); adminPdsEndpoint = config.pdsApi; } catch { adminPdsEndpoint = 'https://bsky.social'; // Fallback } const userCollectionUrl = `${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`; const response = await fetch(userCollectionUrl); if (!response.ok) { return getDefaultUsers(); } const data = await response.json(); const userRecords = data.records || []; if (userRecords.length === 0) { const defaultUsers = getDefaultUsers(); return defaultUsers; } // レコードからユーザーリストを構築し、プレースホルダー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')) { // Resolving placeholder DID using proper PDS detection try { const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(user.handle)); if (profile && profile.did) { // Resolved DID return { ...user, did: profile.did }; } } catch (err) { // Failed to resolve DID } } return user; }) ); allUsers.push(...resolvedUsers); } } return allUsers; } catch (err) { // Failed to load users from records, using defaults return getDefaultUsers(); } }; // ユーザーリスト一覧を読み込み const loadUserListRecords = async () => { try { // Loading user list records using proper PDS detection const currentAdminDid = adminDid || appConfig.adminDid; // Use per-user PDS detection for admin's records let adminPdsEndpoint; try { const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid)); const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); adminPdsEndpoint = config.pdsApi; } catch { adminPdsEndpoint = 'https://bsky.social'; // Fallback } const response = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`); if (!response.ok) { // 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() ); // Loaded user list records setUserListRecords(sortedRecords); } catch (err) { // Failed to load user list records setUserListRecords([]); } }; const getDefaultUsers = () => { const currentAdminDid = adminDid || appConfig.adminDid; const defaultUsers = [ // Default admin user { did: currentAdminDid, handle: appConfig.adminHandle, pds: 'https://syu.is' }, ]; // 現在ログインしているユーザーも追加(重複チェック) if (user && user.did && user.handle && !defaultUsers.find(u => u.did === user.did)) { // Detect PDS based on handle const userPds = user.handle.endsWith('.syu.is') ? 'https://syu.is' : user.handle.endsWith('.syui.ai') ? 'https://syu.is' : 'https://bsky.social'; defaultUsers.push({ did: user.did, handle: user.handle, pds: userPds }); } return defaultUsers; }; // 新しい関数: 全ユーザーからコメントを収集 const loadAllComments = async (pageUrl?: string) => { try { // ユーザーリストを動的に取得 const knownUsers = await loadUsersFromRecord(); const allComments = []; // 各ユーザーからコメントを収集 for (const user of knownUsers) { try { // Use per-user PDS detection for repo operations let pdsEndpoint; try { const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(user.did)); const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); pdsEndpoint = config.pdsApi; } catch { // Fallback to user.pds if PDS detection fails pdsEndpoint = user.pds; } const collections = getCollectionNames(appConfig.collections.base); const response = await fetch(`${pdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(user.did)}&collection=${encodeURIComponent(collections.comment)}&limit=100`); if (!response.ok) { continue; } const data = await response.json(); const userRecords = data.records || []; // 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); } } // ページpathでフィルタリング(指定された場合) const filteredComments = pageUrl && appConfig.rkey ? userComments.filter(record => { try { // Compare rkey only (last part of path) const recordRkey = record.value.url ? new URL(record.value.url).pathname.split('/').pop() : ''; return recordRkey === appConfig.rkey; } catch (err) { return false; } }) : userComments; allComments.push(...filteredComments); } catch (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 { // Use per-user PDS detection for profile fetching const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(record.value.author.handle)); if (profile) { // Determine network config based on profile data let webUrl = 'https://bsky.app'; // Default to Bluesky if (profile.avatar && profile.avatar.includes('cdn.bsky.app')) { webUrl = 'https://bsky.app'; } else if (profile.avatar && profile.avatar.includes('bsky.syu.is')) { webUrl = 'https://web.syu.is'; } return { ...record, value: { ...record.value, author: { ...record.value.author, avatar: profile.avatar, displayName: profile.displayName || record.value.author.handle, _webUrl: webUrl, // Store network config for profile URL generation } } }; } } catch (err) { // Ignore enhancement errors } } return record; }) ); // デバッグ情報を追加 setComments(enhancedComments); // キャッシュに保存(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) { 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: getCollectionNames(appConfig.collections.base).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 } // Add new comment to the array existingComments.push(newComment); // Create the record with comments array const record = { $type: getCollectionNames(appConfig.collections.base).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: getCollectionNames(appConfig.collections.base).comment, rkey: rkey, record: record, }); // Clear form and reload all comments setCommentText(''); await loadAllComments(window.location.href); } catch (err: any) { 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]; // Delete the record await agent.api.com.atproto.repo.deleteRecord({ repo: user.did, collection: getCollectionNames(appConfig.collections.base).comment, rkey: rkey, }); // Reload all comments to reflect the deletion await loadAllComments(window.location.href); } catch (err: any) { 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 === adminDid || 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; } } } catch (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: getCollectionNames(appConfig.collections.base).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: getCollectionNames(appConfig.collections.base).user, rkey: rkey, record: record, }); // Clear form and reload user list records setUserListInput(''); loadUserListRecords(); alert('ユーザーリストが更新されました'); } catch (err: any) { 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]; // Delete the record await agent.api.com.atproto.repo.deleteRecord({ repo: user.did, collection: getCollectionNames(appConfig.collections.base).user, rkey: rkey, }); loadUserListRecords(); alert('ユーザーリストが削除されました'); } catch (err: any) { 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) { alert('認証の開始に失敗しました。再度お試しください。'); } }; // ユーザーハンドルからプロフィールURLを生成 const generateProfileUrl = (author: any): string => { // Check if this is admin/AI handle that should use configured PDS if (author.handle === appConfig.adminHandle || author.handle === appConfig.aiHandle) { const config = getNetworkConfig(appConfig.atprotoPds); return `${config.webUrl}/profile/${author.did}`; } // For ai.syu.is handle, also use configured PDS if (author.handle === 'ai.syu.is') { const config = getNetworkConfig(appConfig.atprotoPds); return `${config.webUrl}/profile/${author.did}`; } // For other users, detect network based on avatar URL or stored network info if (author.avatar && author.avatar.includes('cdn.bsky.app')) { // User has Bluesky avatar, use Bluesky web interface return `https://bsky.app/profile/${author.did}`; } // Check if we have stored network config from profile fetching if (author._webUrl) { return `${author._webUrl}/profile/${author.did}`; } // Fallback: Get PDS from handle for other users const pds = detectPdsFromHandle(author.handle); const config = getNetworkConfig(pds); // Use DID for profile URL return `${config.webUrl}/profile/${author.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 // Handle both original records and flattened records from new array format const uri = record.uri || record.originalRecord?.uri; if (!uri) { return false; } const uriParts = 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 // Unified rendering function for AI content const renderAIContent = (record: any, index: number, className: string) => { // Handle both new format (record.value.$type) and old format compatibility const value = record.value; const isNewFormat = value.$type && value.post && value.author; // Extract content based on format const contentText = isNewFormat ? value.text : (value.content || value.body || ''); // Use the author from the record if available, otherwise fall back to AI profile const authorInfo = value.author || aiProfile; const postInfo = isNewFormat ? value.post : null; const contentType = value.type || 'unknown'; const createdAt = value.createdAt || value.generated_at || ''; return (
AI Avatar {new Date(createdAt).toLocaleString()}
{(postInfo?.url || value.post_url) && ( {postInfo?.url || value.post_url} )}
{/* JSON Display */} {showJsonFor === record.uri && (
JSON Record:
              {JSON.stringify(record, null, 2)}
            
)}
{contentText?.split('\n').map((line: string, index: number) => ( {line} {index < contentText.split('\n').length - 1 &&
}
))}
); }; const getTypeLabel = (collectionType: string, contentType: string) => { if (!collectionType) return contentType; const collections = getCollectionNames(appConfig.collections.base); if (collectionType === collections.chat) { return contentType === 'question' ? '質問' : '回答'; } if (collectionType === collections.chatLang) { return `翻訳: ${contentType.toUpperCase()}`; } if (collectionType === collections.chatComment) { return `AI ${contentType}`; } return contentType; }; 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 */}