Files
log/oauth/src/App.tsx
2025-06-14 00:05:29 +09:00

1262 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [comments, setComments] = useState<any[]>([]);
const [commentText, setCommentText] = useState('');
const [isPosting, setIsPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [handleInput, setHandleInput] = useState('');
const [userListInput, setUserListInput] = useState('');
const [isPostingUserList, setIsPostingUserList] = useState(false);
const [userListRecords, setUserListRecords] = useState<any[]>([]);
const [showJsonFor, setShowJsonFor] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'comments' | 'ai-chat'>('comments');
const [aiChatHistory, setAiChatHistory] = useState<any[]>([]);
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<User> => {
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 (
<div className="app">
<main className="app-main">
<section className="comment-section">
{/* Authentication Section */}
{!user ? (
<div className="auth-section">
<button
onClick={executeOAuth}
className="atproto-button"
>
atproto
</button>
<div className="username-input-section">
<input
type="text"
placeholder="user.bsky.social"
className="handle-input"
value={handleInput}
onChange={(e) => setHandleInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
executeOAuth();
}
}}
/>
</div>
</div>
) : (
<div className="user-section">
<div className="user-info">
<div className="user-profile">
<img
src={user.avatar || generatePlaceholderAvatar(user.handle)}
alt="User Avatar"
className="user-avatar"
/>
<div className="user-details">
<h3>{user.displayName || user.handle}</h3>
<p className="user-handle">@{user.handle}</p>
<p className="user-did">{user.did}</p>
</div>
</div>
<button onClick={handleLogout} className="logout-button">
Logout
</button>
</div>
{/* Admin Section - User Management */}
{isAdmin(user) && (
<div className="admin-section">
<h3> - </h3>
{/* User List Form */}
<div className="user-list-form">
<textarea
value={userListInput}
onChange={(e) => setUserListInput(e.target.value)}
placeholder="ユーザーハンドルをカンマ区切りで入力&#10;例: syui.ai, yui.syui.ai, user.bsky.social"
rows={3}
disabled={isPostingUserList}
/>
<div className="form-actions">
<span className="admin-hint"></span>
<button
onClick={handlePostUserList}
disabled={isPostingUserList || !userListInput.trim()}
className="post-button"
>
{isPostingUserList ? 'Posting...' : 'Post User List'}
</button>
</div>
</div>
{/* User List Records */}
<div className="user-list-records">
<h4> ({userListRecords.length})</h4>
{userListRecords.length === 0 ? (
<p className="no-user-lists"></p>
) : (
userListRecords.map((record, index) => (
<div key={index} className="user-list-item">
<div className="user-list-header">
<span className="user-list-date">
{new Date(record.value.createdAt).toLocaleString()}
</span>
<div className="user-list-actions">
<button
onClick={() => toggleJsonDisplay(record.uri)}
className="json-button"
title="Show/Hide JSON"
>
{showJsonFor === record.uri ? 'Hide JSON' : 'Show JSON'}
</button>
<button
onClick={() => handleDeleteUserList(record.uri)}
className="delete-button"
title="Delete user list"
>
Delete
</button>
</div>
</div>
<div className="user-list-content">
<div className="user-handles">
{record.value.users && record.value.users.map((user, userIndex) => (
<span key={userIndex} className="user-handle-tag">
{user.handle}
<small className="pds-info">({new URL(user.pds).hostname})</small>
</span>
))}
</div>
<div className="user-list-meta">
<small>URI: {record.uri}</small>
<br />
<small>Updated by: {record.value.updatedBy?.handle || 'unknown'}</small>
</div>
{/* JSON Display */}
{showJsonFor === record.uri && (
<div className="json-display">
<h5>JSON Record:</h5>
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
</div>
</div>
))
)}
</div>
</div>
)}
</div>
)}
{/* Tab Navigation */}
<div className="tab-navigation">
<button
className={`tab-button ${activeTab === 'comments' ? 'active' : ''}`}
onClick={() => setActiveTab('comments')}
>
Comments ({comments.filter(shouldShowComment).length})
</button>
{user && (
<button
className={`tab-button ${activeTab === 'ai-chat' ? 'active' : ''}`}
onClick={() => setActiveTab('ai-chat')}
>
AI Chat History ({aiChatHistory.length})
</button>
)}
</div>
{/* Comments List */}
{activeTab === 'comments' && (
<div className="comments-list">
<div className="comments-header">
<h3>Comments</h3>
</div>
{comments.filter(shouldShowComment).length === 0 ? (
<p className="no-comments">
{appConfig.rkey ? `No comments for this post yet` : `No comments yet`}
</p>
) : (
comments.filter(shouldShowComment).map((record, index) => (
<div key={index} className="comment-item">
<div className="comment-header">
<img
src={generatePlaceholderAvatar(record.value.author?.handle || 'unknown')}
alt="User Avatar"
className="comment-avatar"
ref={(img) => {
// Fetch fresh avatar from API when component mounts
if (img && record.value.author?.did) {
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.did)}`)
.then(res => res.json())
.then(data => {
if (data.avatar && img) {
img.src = data.avatar;
}
})
.catch(err => {
console.warn('Failed to fetch fresh avatar:', err);
// Keep placeholder on error
});
}
}}
/>
<div className="comment-author-info">
<span className="comment-author">
{record.value.author?.displayName || record.value.author?.handle || 'unknown'}
</span>
<a
href={generateProfileUrl(record.value.author?.handle || '', record.value.author?.did || '')}
target="_blank"
rel="noopener noreferrer"
className="comment-handle"
>
@{record.value.author?.handle || 'unknown'}
</a>
</div>
<span className="comment-date">
{new Date(record.value.createdAt).toLocaleString()}
</span>
<div className="comment-actions">
<button
onClick={() => toggleJsonDisplay(record.uri)}
className="json-button"
title="Show/Hide JSON"
>
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
</button>
{/* Show delete button only for current user's comments */}
{user && record.value.author?.did === user.did && (
<button
onClick={() => handleDeleteComment(record.uri)}
className="delete-button"
title="Delete comment"
>
Delete
</button>
)}
</div>
</div>
<div className="comment-content">
{record.value.text}
</div>
<div className="comment-meta">
<small>{record.uri}</small>
</div>
{/* JSON Display */}
{showJsonFor === record.uri && (
<div className="json-display">
<h5>JSON Record:</h5>
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
</div>
))
)}
</div>
)}
{/* AI Chat History List */}
{activeTab === 'ai-chat' && user && (
<div className="ai-chat-list">
<div className="chat-header">
<h3>AI Chat History</h3>
</div>
{aiChatHistory.length === 0 ? (
<p className="no-chat">No AI conversations yet. Start chatting with Ask AI!</p>
) : (
aiChatHistory.map((record, index) => (
<div key={index} className="chat-item">
<div className="chat-header">
<img
src={generatePlaceholderAvatar(record.value.author?.handle || 'unknown')}
alt="User Avatar"
className="comment-avatar"
ref={(img) => {
// Fetch fresh avatar from API when component mounts
if (img && record.value.author?.did) {
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.did)}`)
.then(res => res.json())
.then(data => {
if (data.avatar && img) {
img.src = data.avatar;
}
})
.catch(err => {
console.warn('Failed to fetch fresh avatar:', err);
// Keep placeholder on error
});
}
}}
/>
<div className="comment-author-info">
<span className="comment-author">
{record.value.author?.displayName || record.value.author?.handle || 'unknown'}
</span>
<a
href={generateProfileUrl(record.value.author?.handle || '', record.value.author?.did || '')}
target="_blank"
rel="noopener noreferrer"
className="comment-handle"
>
@{record.value.author?.handle || 'unknown'}
</a>
</div>
<span className="comment-date">
{new Date(record.value.createdAt).toLocaleString()}
</span>
<div className="comment-actions">
<button
onClick={() => toggleJsonDisplay(record.uri)}
className="json-button"
title="Show/Hide JSON"
>
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
</button>
<button className="chat-type-button">
{record.value.question ? 'Question' : 'Answer'}
</button>
</div>
</div>
<div className="comment-content">
{record.value.question || record.value.answer}
</div>
<div className="comment-meta">
<small>{record.uri}</small>
</div>
{/* JSON Display */}
{showJsonFor === record.uri && (
<div className="json-display">
<h5>JSON Record:</h5>
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
</div>
))
)}
</div>
)}
{/* Comment Form - Only show on post pages */}
{user && appConfig.rkey && (
<div className="comment-form">
<h3>Post a Comment</h3>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Write your comment..."
rows={4}
disabled={isPosting}
/>
<div className="form-actions">
<span className="char-count">{commentText.length} / 1000</span>
<button
onClick={handlePostComment}
disabled={isPosting || !commentText.trim() || commentText.length > 1000}
className="post-button"
>
{isPosting ? 'Posting...' : 'Post Comment'}
</button>
</div>
{error && <p className="error">{error}</p>}
</div>
)}
{/* Show authentication status on non-post pages */}
{user && !appConfig.rkey && (
<div className="auth-status">
<p> Authenticated as @{user.handle}</p>
<p><small>Visit a post page to comment</small></p>
</div>
)}
</section>
</main>
{/* AI Chat Component - handles all AI functionality */}
<AIChat user={user} isEnabled={appConfig.aiEnabled && appConfig.aiAskAi} />
</div>
);
}
export default App;