Files
log/oauth/src/App.tsx
2025-06-17 17:43:03 +09:00

1632 lines
59 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, 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<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' | 'lang-en' | 'ai-comment'>('comments');
const [aiChatHistory, setAiChatHistory] = useState<any[]>([]);
const [langEnRecords, setLangEnRecords] = useState<any[]>([]);
const [aiCommentRecords, setAiCommentRecords] = useState<any[]>([]);
const [aiProfile, setAiProfile] = useState<any>(null);
const [adminDid, setAdminDid] = useState<string | null>(null);
const [aiDid, setAiDid] = useState<string | null>(null);
// ハンドルからDIDを解決する関数
const resolveHandleToDid = async (handle: string): Promise<string | null> => {
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<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) {
// 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 = `<svg width="48" height="48" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" fill="#1185fe"/>
<text x="24" y="32" font-family="Arial, sans-serif" font-size="20" font-weight="bold" fill="white" text-anchor="middle">${initial}</text>
</svg>`;
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 (
<div key={index} className={className}>
<div className="comment-header">
<img
src={authorInfo?.avatar || generatePlaceholderAvatar('AI')}
alt="AI Avatar"
className="comment-avatar"
/>
<div className="comment-author-info">
<span className="comment-author">
{authorInfo?.displayName || 'ai'}
</span>
<a
href={generateProfileUrl({
handle: authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle,
did: authorInfo?.did || aiProfile?.did || appConfig.aiDid
})}
target="_blank"
rel="noopener noreferrer"
className="comment-handle"
>
@{authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle}
</a>
</div>
<span className="comment-date">
{new Date(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>
</div>
</div>
<div className="comment-meta">
{(postInfo?.url || value.post_url) && (
<small>
<a href={postInfo?.url || value.post_url}>
{postInfo?.url || value.post_url}
</a>
</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 className="comment-content">
{contentText?.split('\n').map((line: string, index: number) => (
<React.Fragment key={index}>
{line}
{index < contentText.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</div>
</div>
);
};
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 (
<div className="app">
<main className="app-main">
<section className="comment-section">
{/* Authentication Section */}
{!user ? (
<div className="auth-section search-bar-layout">
<input
type="text"
id="handle-input"
name="handle"
placeholder="user.bsky.social"
className="handle-input"
value={handleInput}
onChange={(e) => setHandleInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
executeOAuth();
}
}}
/>
<button
onClick={executeOAuth}
className="atproto-button"
>
<i class="fab fa-bluesky"></i>
</button>
</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
id="user-list-input"
name="userList"
value={userListInput}
onChange={(e) => setUserListInput(e.target.value)}
placeholder="ユーザーハンドルをカンマ区切りで入力&#10;例: syui.ai, ai.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')}
>
comment ({comments.filter(shouldShowComment).length})
</button>
<button
className={`tab-button ${activeTab === 'ai-chat' ? 'active' : ''}`}
onClick={() => setActiveTab('ai-chat')}
>
chat ({aiChatHistory.length})
</button>
<button
className={`tab-button ${activeTab === 'lang-en' ? 'active' : ''}`}
onClick={() => setActiveTab('lang-en')}
>
en ({langEnRecords.length})
</button>
<button
className={`tab-button ${activeTab === 'ai-comment' ? 'active' : ''}`}
onClick={() => setActiveTab('ai-comment')}
>
feedback ({aiCommentRecords.length})
</button>
</div>
{/* Comments List */}
{activeTab === 'comments' && (
<div className="comments-list">
{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={record.value.author?.avatar || generatePlaceholderAvatar(record.value.author?.handle || 'unknown')}
alt="User Avatar"
className="comment-avatar"
/>
<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)}
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-meta">
{record.value.url && (
<small><a href={record.value.url}>{record.value.url}</a></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 className="comment-content">
{record.value.text?.split('\n').map((line: string, index: number) => (
<React.Fragment key={index}>
{line}
{index < record.value.text.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</div>
</div>
))
)}
</div>
)}
{/* AI Chat History List */}
{activeTab === 'ai-chat' && (
<div className="comments-list">
{aiChatHistory.length === 0 ? (
<p className="no-chat">No AI conversations yet. Start chatting with Ask AI!</p>
) : (
aiChatHistory.map((record, index) =>
renderAIContent(record, index, 'comment-item')
)
)}
</div>
)}
{/* Lang: EN List */}
{activeTab === 'lang-en' && (
<div className="comments-list">
{langEnRecords.length === 0 ? (
<p className="no-content">No EN translations yet</p>
) : (
langEnRecords.map((record, index) =>
renderAIContent(record, index, 'lang-item')
)
)}
</div>
)}
{/* AI Comment List */}
{activeTab === 'ai-comment' && (
<div className="comments-list">
{aiCommentRecords.length === 0 ? (
<p className="no-content">No AI comments yet</p>
) : (
aiCommentRecords.map((record, index) =>
renderAIContent(record, index, 'comment-item')
)
)}
</div>
)}
{/* Comment Form - Only show on post pages when Comments tab is active */}
{user && appConfig.rkey && activeTab === 'comments' && (
<div className="comment-form">
<textarea
id="comment-text"
name="commentText"
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>
)}
</section>
</main>
{/* AI Chat Component - handles all AI functionality */}
<AIChat user={user} isEnabled={appConfig.aiEnabled && appConfig.aiAskAi} />
</div>
);
}
export default App;