diff --git a/samples/react-bluesky/src/webparts/blueSky/components/BlueSky.module.scss b/samples/react-bluesky/src/webparts/blueSky/components/BlueSky.module.scss index a7c456a31c..5dd244fdfd 100644 --- a/samples/react-bluesky/src/webparts/blueSky/components/BlueSky.module.scss +++ b/samples/react-bluesky/src/webparts/blueSky/components/BlueSky.module.scss @@ -5,6 +5,7 @@ grid-template-columns: repeat(2, 1fr); /* Two columns */ gap: 20px; /* Consistent gap between cards */ grid-auto-rows: minmax(100px, auto); /* Ensure consistent row height */ + grid-auto-flow: dense; /* Fill in gaps with smaller items */ } .card { @@ -47,6 +48,9 @@ .cardContent { margin-top: 10px; color: black; /* Set post text color to black */ + white-space: pre-wrap; /* Ensure text wraps properly */ + word-wrap: break-word; /* Handle long words or links */ + overflow-wrap: break-word; /* Handle long words or links */ } .cardImagesContainer { @@ -78,21 +82,23 @@ .statItem { align-items: center; display: flex; + cursor: pointer; /* Add cursor pointer for clickable items */ } .statIcon { - color: #0078d4; + color: #0078d4; /* Blue color */ margin-right: 5px; - font-size: 10px; + font-size: 14px; /* Make icons smaller */ + transition: color 0.2s ease; /* Add transition for color change */ } .statText { - color: #0078d4; + color: #0078d4; /* Blue color */ margin-right: 10px; } .hashtag { - color: #0078d4; + color: #0078d4; /* Blue color */ cursor: pointer; font-weight: 500; margin-right: 5px; @@ -105,4 +111,44 @@ &:visited { color: #0078d4; /* Ensure the color remains the same for visited links */ } +} + +.liked { + color: red; /* Change color to red when liked */ +} + +.statItem:hover .statIcon { + color: red; /* Change color to red on hover */ +} + +.linkPreview { + display: flex; + margin-top: 10px; +} + +.linkImage { + border-radius: 8px; + height: 100px; + width: 100px; + margin-right: 10px; +} + +.linkDetails { + display: flex; + flex-direction: column; +} + +.linkTitle { + color: #0078d4; /* Blue color */ + font-weight: bold; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.linkDescription { + color: #555; + font-size: 0.9rem; } \ No newline at end of file diff --git a/samples/react-bluesky/src/webparts/blueSky/components/BlueSky.tsx b/samples/react-bluesky/src/webparts/blueSky/components/BlueSky.tsx index 94b9d9c8fa..acf55a2329 100644 --- a/samples/react-bluesky/src/webparts/blueSky/components/BlueSky.tsx +++ b/samples/react-bluesky/src/webparts/blueSky/components/BlueSky.tsx @@ -3,11 +3,12 @@ import { Card, CardSection } from '@fluentui/react-cards'; import { initializeIcons } from '@fluentui/react'; import { Icon } from '@fluentui/react/lib/Icon'; import BlueSkyImageSection from './BlueSkyImageSection'; -import BlueSkyAuthorSection from './BlueSkyAuthorSection'; import BlueSkyContentSection from './BlueSkyContentSection'; import BlueSkyTimestampSection from './BlueSkyTimestampSection'; import useAccessToken from './useAccessToken'; import useBlueSkyPosts from './useBlueSkyPosts'; +import useLikePost from './useLikePost'; +import usePostMetrics from './usePostMetrics'; import styles from './BlueSky.module.scss'; import { IBlueSkyProps } from './IBlueSkyProps'; import axios from 'axios'; @@ -16,61 +17,32 @@ import axios from 'axios'; initializeIcons(); const pdsUrl = "https://bsky.social"; -const getPostThreadEndpoint = `${pdsUrl}/xrpc/app.bsky.feed.getPostThread`; - -const getPostMetrics = async (accessToken: string, postUri: string): Promise<{ likeCount: number; shareCount: number; replyCount: number }> => { - const headers = { Authorization: `Bearer ${accessToken}` }; - const params = { uri: postUri }; - - try { - const response = await axios.get(getPostThreadEndpoint, { headers, params }); - const data = response.data; - - console.log('API Response:', data); // Log the full API response - - const thread = data.thread || {}; - const post = thread.post || {}; - - console.log('Post Data:', post); // Log the post data - - const likeCount = post.likeCount || 0; - const shareCount = post.reshareCount || 0; - const replyCount = post.replyCount || 0; - - return { likeCount, shareCount, replyCount }; - } catch (error) { - console.error(`Failed to fetch metrics for post ${postUri}`, error); - throw error; - } -}; const BlueSky: React.FC = (props) => { - const { accessToken, error: tokenError } = useAccessToken('luisefreese.bsky.social', 'txmo-2fvo-vwb3-3vwa'); + const { accessToken, error: tokenError } = useAccessToken('.bsky.social', 'your app password'); // replace! const { posts, loading, error: postsError } = useBlueSkyPosts(accessToken); - - const [metrics, setMetrics] = useState<{ [key: string]: { likeCount: number, shareCount: number, replyCount: number } }>({}); + const [did, setDid] = useState(''); useEffect(() => { - const fetchMetrics = async (): Promise => { + const fetchDid = async (): Promise => { if (accessToken) { - const newMetrics: { [key: string]: { likeCount: number, shareCount: number, replyCount: number } } = {}; - for (const post of posts) { - const postUri = post.uri.startsWith('at://') ? post.uri : `at://${post.uri}`; - try { - const postMetrics = await getPostMetrics(accessToken, postUri); - newMetrics[post.id] = postMetrics; - } catch (error) { - console.error(`Failed to fetch metrics for post ${post.id}`, error); - } + try { + const response = await axios.get( + `${pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=.bsky.social`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + setDid(response.data.did); + } catch (error) { + console.error('Failed to fetch DID', error); } - setMetrics(newMetrics); } }; - fetchMetrics() - .then(() => console.log("Metrics fetched successfully")) - .catch((error) => console.error("Error fetching metrics:", error)); - }, [accessToken, posts]); + fetchDid().catch((error) => console.error('Error fetching DID:', error)); + }, [accessToken]); + + const { likedPosts, handleLikeClick } = useLikePost(accessToken || '', did || ''); + const metrics = usePostMetrics(accessToken || '', posts); return (
@@ -82,41 +54,46 @@ const BlueSky: React.FC = (props) => { {posts.map((post) => { const lastUriSegment = post.uri.split('/').pop(); const postUrl = `https://bsky.app/profile/${post.author.handle}/post/${lastUriSegment}`; + const profileUrl = `https://bsky.app/profile/${post.author.handle}`; const postMetrics = metrics[post.id] || { likeCount: 0, shareCount: 0, replyCount: 0 }; + const isLiked = likedPosts.has(post.id); return ( - - - - - - + + +
window.open(profileUrl, '_blank')}> + {post.author.displayName} + {post.author.displayName} +
+
+ +
diff --git a/samples/react-bluesky/src/webparts/blueSky/components/BlueSkyAuthorSection.tsx b/samples/react-bluesky/src/webparts/blueSky/components/BlueSkyAuthorSection.tsx index 0f302b61ca..67b30ac351 100644 --- a/samples/react-bluesky/src/webparts/blueSky/components/BlueSkyAuthorSection.tsx +++ b/samples/react-bluesky/src/webparts/blueSky/components/BlueSkyAuthorSection.tsx @@ -8,7 +8,7 @@ interface BlueSkyAuthorSectionProps { } const BlueSkyAuthorSection: React.FC = ({ avatar, author }) => { - console.log('Author:', author); // Log the author prop to the console + return (
diff --git a/samples/react-bluesky/src/webparts/blueSky/components/useBlueSkyPosts.ts b/samples/react-bluesky/src/webparts/blueSky/components/useBlueSkyPosts.ts index a2f8c97e92..33abbe9eb0 100644 --- a/samples/react-bluesky/src/webparts/blueSky/components/useBlueSkyPosts.ts +++ b/samples/react-bluesky/src/webparts/blueSky/components/useBlueSkyPosts.ts @@ -10,85 +10,59 @@ interface BlueSkyPost { did: string; handle: string; }; - images?: { - alt: string; - aspectRatio: { - height: number; - width: number; - }; - image: { - $type: string; - mimeType: string; - ref: { - $link: string; - }; - size: number; - }; - }[]; // Include the images property + images?: BlueSkyImage[]; did: string; uri: string; replyCount: number; reshareCount: number; likeCount: number; + parent?: string; // Add parent property to identify replies } -interface BlueSkyApiResponse { - feed: { post: BlueSkyPostItem }[]; - cursor?: string; +interface BlueSkyImage { + alt: string; + aspectRatio: { + height: number; + width: number; + }; + image: { + $type: string; + mimeType: string; + ref: { + $link: string; + }; + size: number; + }; } interface BlueSkyPostItem { - cid: string; - uri: string; - record: { - text: string; - createdAt: string; - embed?: { - images?: { - alt: string; - aspectRatio: { - height: number; - width: number; - }; - image: { - $type: string; - mimeType: string; - ref: { - $link: string; - }; - size: number; - }; - }[]; + post: { + cid: string; + record: { + text: string; + createdAt: string; + parent?: string; + embed?: { + images?: BlueSkyImage[]; + }; }; + author: { + displayName: string; + avatar?: string; + did: string; + handle: string; + }; + uri: string; + replyCount: number; + reshareCount: number; + likeCount: number; }; - author: { - displayName: string; - avatar?: string; - did: string; - handle: string; - }; - replyCount: number; - reshareCount: number; - likeCount: number; } -const fetchWithRateLimitRetry = async (url: string, options: RequestInit, retries = 3, delay = 2000): Promise => { - try { - const response = await fetch(url, options); - if (response.status === 429) throw new Error("Rate Limit Exceeded"); - if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`); - - const jsonData = await response.json(); - return jsonData; - } catch (error: unknown) { - if (retries > 0 && error instanceof Error && error.message === "Rate Limit Exceeded") { - await new Promise((resolve) => setTimeout(resolve, delay)); - return fetchWithRateLimitRetry(url, options, retries - 1, delay * 2); - } else { - throw error; - } - } -}; +interface BlueSkyApiResponse { + feed: BlueSkyPostItem[]; + cursor?: string; +} const useBlueSkyPosts = (accessToken: string | undefined): { posts: BlueSkyPost[], loading: boolean, error: string | undefined } => { const [posts, setPosts] = useState([]); @@ -102,40 +76,26 @@ const useBlueSkyPosts = (accessToken: string | undefined): { posts: BlueSkyPost[ setLoading(true); setError(undefined); - const cacheKey = 'blueSkyPosts'; - const cacheExpiryKey = 'blueSkyPostsExpiry'; - const cacheExpiryTime = 60 * 60 * 1000; // 1 hour - - const cachedPosts = localStorage.getItem(cacheKey); - const cachedExpiry = localStorage.getItem(cacheExpiryKey); - - if (cachedPosts && cachedExpiry && Date.now() < parseInt(cachedExpiry, 10)) { - setPosts(JSON.parse(cachedPosts)); - setLoading(false); - return; - } - - const hashtags = ["#Microsoft365", "#SPFx", "#SharePoint", "#MSIgnite", "PowerPlatform", "Microsoft365Dev", "#SharingIsCaring"]; // Add the #SharingIsCaring hashtag - const filteredPosts: BlueSkyPost[] = []; - let cursor: string | undefined = undefined; - try { - while (filteredPosts.length < 10) { // Limit to 10 posts - const url: string = `https://bsky.social/xrpc/app.bsky.feed.getTimeline${cursor ? `?cursor=${cursor}` : ''}`; - const data = await fetchWithRateLimitRetry(url, { - method: 'GET', + let allPosts: BlueSkyPost[] = []; + let cursor: string | undefined = undefined; + const postIds = new Set(); // Track unique post IDs + const hashtags = ["#SharePoint", "#Microsoft365", "#Microsoft365Dev", "Microsoft", "#MicrosoftTeams", "#SPFx", "#SharingIsCaring", "#MsIgnite", "PowerPlatform", "Azure"]; + const desiredPostCount = 10; // Set the desired number of posts to fetch + + while (allPosts.length < desiredPostCount) { + const response = await fetch(`https://bsky.social/xrpc/app.bsky.feed.getTimeline${cursor ? `?cursor=${cursor}` : ''}`, { headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, }, - }) as BlueSkyApiResponse; - - const feed = data.feed; - - // Filter posts that contain specified hashtags - const matchingPosts = feed - .filter(item => hashtags.some(tag => item.post.record.text.includes(tag))) - .map(item => ({ + }); + const data: BlueSkyApiResponse = await response.json(); + + const newPosts: BlueSkyPost[] = data.feed + .filter((item: BlueSkyPostItem) => { + return !item.post.record.parent && hashtags.some(tag => item.post.record.text.includes(tag)); + }) + .map((item: BlueSkyPostItem) => ({ id: item.post.cid, content: item.post.record.text, timestamp: item.post.record.createdAt, @@ -145,24 +105,33 @@ const useBlueSkyPosts = (accessToken: string | undefined): { posts: BlueSkyPost[ did: item.post.author.did, handle: item.post.author.handle, }, - images: item.post.record.embed?.images || [], // Map images + images: item.post.record.embed?.images || [], did: item.post.author.did, uri: item.post.uri, replyCount: item.post.replyCount, reshareCount: item.post.reshareCount, likeCount: item.post.likeCount, - })); - - filteredPosts.push(...matchingPosts); - - if (filteredPosts.length >= 10) break; + })) + .filter(post => { + const isDuplicate = postIds.has(post.id); + console.log(`Post ID: ${post.id}, Is Duplicate: ${isDuplicate}`); + return !isDuplicate; + }); + + newPosts.forEach(post => { + console.log(`Adding Post ID: ${post.id} to Set`); + postIds.add(post.id); + }); + + allPosts = [...allPosts, ...newPosts]; cursor = data.cursor; - if (!cursor) break; + + if (!cursor) break; // Exit loop if no more posts are available } - setPosts(filteredPosts.slice(0, 10)); - localStorage.setItem(cacheKey, JSON.stringify(filteredPosts.slice(0, 10))); - localStorage.setItem(cacheExpiryKey, (Date.now() + cacheExpiryTime).toString()); + console.log('Filtered Posts:', allPosts.map(post => post.id)); // Log the filtered post IDs + + setPosts(allPosts.slice(0, desiredPostCount)); // Limit to the desired number of posts } catch (err) { if (err instanceof Error) { setError(err.message); @@ -173,9 +142,13 @@ const useBlueSkyPosts = (accessToken: string | undefined): { posts: BlueSkyPost[ }; fetchBlueSkyPosts().catch((err) => console.error("Failed to fetch posts:", err)); + + const intervalId = setInterval(fetchBlueSkyPosts, 30000); // Reload every 30 seconds + + return () => clearInterval(intervalId); // Cleanup interval on component unmount }, [accessToken]); return { posts, loading, error }; }; -export default useBlueSkyPosts; +export default useBlueSkyPosts; \ No newline at end of file diff --git a/samples/react-bluesky/src/webparts/blueSky/components/useLikePost.ts b/samples/react-bluesky/src/webparts/blueSky/components/useLikePost.ts new file mode 100644 index 0000000000..671f3ab6d5 --- /dev/null +++ b/samples/react-bluesky/src/webparts/blueSky/components/useLikePost.ts @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import axios from 'axios'; + +const pdsUrl = "https://bsky.social"; +const likePostEndpoint = `${pdsUrl}/xrpc/com.atproto.repo.createRecord`; +const getPostThreadEndpoint = `${pdsUrl}/xrpc/app.bsky.feed.getPostThread`; + +const getPostMetrics = async (accessToken: string, postUri: string): Promise<{ likeCount: number; shareCount: number; replyCount: number; cid: string }> => { + const headers = { Authorization: `Bearer ${accessToken}` }; + const params = { uri: postUri }; + + try { + const response = await axios.get(getPostThreadEndpoint, { headers, params }); + const data = response.data; + + const thread = data.thread || {}; + const post = thread.post || {}; + + const likeCount = post.likeCount || 0; + const shareCount = post.reshareCount || 0; + const replyCount = post.replyCount || 0; + const cid = post.cid || ''; + + return { likeCount, shareCount, replyCount, cid }; + } catch (error) { + console.error(`Failed to fetch metrics for post ${postUri}`, error); + throw error; + } +}; + +const likePost = async (accessToken: string, postUri: string, cid: string, did: string): Promise => { + const headers = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }; + const data = { + repo: did, + collection: 'app.bsky.feed.like', + record: { + $type: 'app.bsky.feed.like', + subject: { + uri: postUri, + cid, + }, + createdAt: new Date().toISOString(), + }, + }; + + try { + const response = await axios.post(likePostEndpoint, data, { headers }); + console.log(`Post ${postUri} liked successfully with uri: ${response.data.uri}`); + } catch (error) { + console.error(`Failed to like post ${postUri}`, error); + throw error; + } +}; + +const useLikePost = (accessToken: string, did: string): { likedPosts: Set; handleLikeClick: (postUri: string, postId: string) => Promise } => { + const [likedPosts, setLikedPosts] = useState>(new Set()); + + const handleLikeClick = async (postUri: string, postId: string): Promise => { + if (accessToken && did) { + try { + const postMetrics = await getPostMetrics(accessToken, postUri); + await likePost(accessToken, postUri, postMetrics.cid, did); + console.log(`Post ${postUri} liked`); + setLikedPosts(prevLikedPosts => { + const newLikedPosts = new Set(prevLikedPosts); + newLikedPosts.add(postId); + return newLikedPosts; + }); + } catch (error) { + console.error(`Failed to like post ${postUri}`, error); + } + } + }; + + return { likedPosts, handleLikeClick }; +}; + +export default useLikePost; \ No newline at end of file diff --git a/samples/react-bluesky/src/webparts/blueSky/components/usePostMetrics.ts b/samples/react-bluesky/src/webparts/blueSky/components/usePostMetrics.ts new file mode 100644 index 0000000000..b55d8f010c --- /dev/null +++ b/samples/react-bluesky/src/webparts/blueSky/components/usePostMetrics.ts @@ -0,0 +1,58 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; + +const pdsUrl = "https://bsky.social"; +const getPostThreadEndpoint = `${pdsUrl}/xrpc/app.bsky.feed.getPostThread`; + +const fetchPostMetrics = async (accessToken: string, postUri: string): Promise<{ likeCount: number; shareCount: number; replyCount: number; cid: string }> => { + const headers = { Authorization: `Bearer ${accessToken}` }; + const params = { uri: postUri }; + + try { + const response = await axios.get(getPostThreadEndpoint, { headers, params }); + const data = response.data; + + const thread = data.thread || {}; + const post = thread.post || {}; + + const likeCount = post.likeCount || 0; + const shareCount = post.reshareCount || 0; + const replyCount = post.replyCount || 0; + const cid = post.cid || ''; + + return { likeCount, shareCount, replyCount, cid }; + } catch (error) { + console.error(`Failed to fetch metrics for post ${postUri}`, error); + throw error; + } +}; + +const usePostMetrics = (accessToken: string, posts: { uri: string; id: string }[]): { [key: string]: { likeCount: number; shareCount: number; replyCount: number } } => { + const [metrics, setMetrics] = useState<{ [key: string]: { likeCount: number; shareCount: number; replyCount: number } }>({}); + + useEffect(() => { + const fetchMetrics = async (): Promise => { + if (accessToken) { + const newMetrics: { [key: string]: { likeCount: number, shareCount: number, replyCount: number } } = {}; + for (const post of posts) { + const postUri = post.uri.startsWith('at://') ? post.uri : `at://${post.uri}`; + try { + const postMetrics = await fetchPostMetrics(accessToken, postUri); + newMetrics[post.id] = postMetrics; + } catch (error) { + console.error(`Failed to fetch metrics for post ${post.id}`, error); + } + } + setMetrics(newMetrics); + } + }; + + fetchMetrics() + .then(() => console.log("Metrics fetched successfully")) + .catch((error) => console.error("Error fetching metrics:", error)); + }, [accessToken, posts]); + + return metrics; +}; + +export default usePostMetrics; \ No newline at end of file