This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 16 kB View raw
1import React, { useState, useEffect, useRef } from 'react'; 2import { useNavigate } from 'react-router-dom'; 3import './AltTextRatingTool.css'; 4 5const PUBLIC_API_URL = "https://public.api.bsky.app"; 6 7const AltTextRatingTool = () => { 8 const [username, setUsername] = useState(''); 9 const [analysis, setAnalysis] = useState(null); 10 const [allRecords, setAllRecords] = useState([]); 11 const [actorDID, setActorDID] = useState(''); 12 const [loading, setLoading] = useState(false); 13 const [excludeReplies, setExcludeReplies] = useState(false); 14 const [shareButtonVisible, setShareButtonVisible] = useState(false); 15 const [textResults, setTextResults] = useState(null); 16 const [suggestions, setSuggestions] = useState([]); 17 const [autocompleteActive, setAutocompleteActive] = useState(false); 18 const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1); 19 const [selectedSuggestion, setSelectedSuggestion] = useState(''); 20 const [showResults, setShowResults] = useState(false); 21 22 const needleGroupRef = useRef(null); 23 const animFrameIdRef = useRef(null); 24 const lastTimestampRef = useRef(null); 25 const currentOscillationRef = useRef(0); 26 const oscillationDirectionRef = useRef(1); 27 28 const navigate = useNavigate(); 29 30 const updateGauge = (percentage) => { 31 const angleDeg = (percentage / 100) * 180; 32 if (needleGroupRef.current) { 33 needleGroupRef.current.setAttribute("transform", `rotate(${angleDeg},200,300)`); 34 } 35 }; 36 37 async function resolveHandleToDID(handle) { 38 const cleanedHandle = handle.replace(/[\u200E\u200F\u202A-\u202E]/g, ''); 39 const res = await fetch(`${PUBLIC_API_URL}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(cleanedHandle)}`); 40 const data = await res.json(); 41 if (data.did) return data.did; 42 throw new Error("Invalid username. Please try again without including the '@' symbol before the domain."); 43 } 44 45 async function fetchServiceEndpoint(did) { 46 let url; 47 if (did.startsWith("did:web:")) { 48 const domain = did.slice("did:web:".length); 49 url = `https://${domain}/.well-known/did.json`; 50 } else if (did.startsWith("did:plc:")) { 51 url = `https://plc.directory/${did}`; 52 } else { 53 throw new Error(`Unsupported DID method for DID: ${did}`); 54 } 55 const res = await fetch(url); 56 const data = await res.json(); 57 if (data.service && data.service.length > 0) { 58 return data.service[0].serviceEndpoint; 59 } 60 throw new Error(`Service endpoint not found for DID: ${did}`); 61 } 62 63 async function fetchRecordsForCollection(serviceEndpoint, did, collectionName) { 64 // Calculate cutoff time for 90 days 65 const cutoffTime = Date.now() - 90 * 24 * 60 * 60 * 1000; 66 67 const urlBase = `${serviceEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collectionName)}&limit=100`; 68 let records = []; 69 let cursor = null; 70 71 while (true) { 72 const url = cursor ? `${urlBase}&cursor=${encodeURIComponent(cursor)}` : urlBase; 73 const res = await fetch(url); 74 const data = await res.json(); 75 76 if (!data || !Array.isArray(data.records) || data.records.length === 0) { 77 break; 78 } 79 80 let minCreatedAt = Infinity; 81 const pageRecords = []; 82 83 for (const rec of data.records) { 84 const createdAt = rec.value?.createdAt; 85 if (!createdAt) continue; 86 87 const recordTime = new Date(createdAt).getTime(); 88 minCreatedAt = Math.min(minCreatedAt, recordTime); 89 90 if (recordTime >= cutoffTime) { 91 pageRecords.push(rec); 92 } 93 } 94 95 records.push(...pageRecords); 96 97 if (minCreatedAt < cutoffTime) { 98 break; 99 } 100 101 if (!data.cursor) { 102 break; 103 } 104 105 cursor = data.cursor; 106 } 107 108 return records; 109 } 110 111 function analyzePosts(records, excludeReplies, actor) { 112 let totalPosts = 0; 113 let postsWithImages = 0; 114 let repliesWithImages = 0; 115 let postsWithAltText = 0; 116 117 records.forEach(rec => { 118 if (!rec.value.createdAt) return; 119 120 const isReply = !!rec.value.reply; 121 let isReplyToSelfFlag = false; 122 123 if (isReply && rec.value.reply?.parent?.author) { 124 isReplyToSelfFlag = rec.value.reply.parent.author.did === actor; 125 } 126 127 if (isReply) { 128 if (isReplyToSelfFlag) { 129 totalPosts += 1; 130 if (rec.value.embed?.["$type"] === "app.bsky.embed.images") { 131 postsWithImages += 1; 132 const hasAltText = rec.value.embed.images.some(img => img.alt?.trim()); 133 if (hasAltText) postsWithAltText += 1; 134 } 135 } else if (!excludeReplies) { 136 totalPosts += 1; 137 if (rec.value.embed?.["$type"] === "app.bsky.embed.images") { 138 postsWithImages += 1; 139 repliesWithImages += 1; 140 const hasAltText = rec.value.embed.images.some(img => img.alt?.trim()); 141 if (hasAltText) postsWithAltText += 1; 142 } 143 } 144 } else { 145 totalPosts += 1; 146 if (rec.value.embed?.["$type"] === "app.bsky.embed.images") { 147 postsWithImages += 1; 148 const hasAltText = rec.value.embed.images.some(img => img.alt?.trim()); 149 if (hasAltText) postsWithAltText += 1; 150 } 151 } 152 }); 153 154 const altTextPercentage = (postsWithAltText / postsWithImages) * 100 || 0; 155 const emojis = ["☹️", "😐", "🙂", "☺️"]; 156 let emoji = emojis[0]; 157 if (altTextPercentage >= 75) emoji = emojis[3]; 158 else if (altTextPercentage >= 50) emoji = emojis[2]; 159 else if (altTextPercentage >= 25) emoji = emojis[1]; 160 161 return { 162 totalPosts, 163 postsWithImages, 164 repliesWithImages, 165 postsWithAltText, 166 altTextPercentage, 167 emoji, 168 }; 169 } 170 171 // Animation code remains the same 172 const baseSpeed = 0.10; 173 const oscillationMin = 0; 174 const oscillationMax = 100; 175 const bounceRange = 10; 176 177 const animateNeedle = (timestamp) => { 178 if (!lastTimestampRef.current) { 179 lastTimestampRef.current = timestamp; 180 } 181 const deltaTime = timestamp - lastTimestampRef.current; 182 lastTimestampRef.current = timestamp; 183 184 const randomFactor = 0.8 + Math.random() * 0.4; 185 const increment = baseSpeed * deltaTime * randomFactor; 186 currentOscillationRef.current += oscillationDirectionRef.current * increment; 187 188 if (currentOscillationRef.current >= oscillationMax) { 189 currentOscillationRef.current = oscillationMax - Math.random() * bounceRange; 190 oscillationDirectionRef.current = -1; 191 } else if (currentOscillationRef.current <= oscillationMin) { 192 currentOscillationRef.current = oscillationMin + Math.random() * bounceRange; 193 oscillationDirectionRef.current = 1; 194 } 195 196 updateGauge(currentOscillationRef.current); 197 animFrameIdRef.current = requestAnimationFrame(animateNeedle); 198 }; 199 200 const startNeedleAnimation = () => { 201 currentOscillationRef.current = oscillationMin; 202 oscillationDirectionRef.current = 1; 203 lastTimestampRef.current = null; 204 if (animFrameIdRef.current) { 205 cancelAnimationFrame(animFrameIdRef.current); 206 } 207 animFrameIdRef.current = requestAnimationFrame(animateNeedle); 208 }; 209 210 const stopNeedleAnimation = () => { 211 if (animFrameIdRef.current) { 212 cancelAnimationFrame(animFrameIdRef.current); 213 animFrameIdRef.current = null; 214 } 215 }; 216 217 // Autocomplete functions remain the same 218 const debounce = (func, delay) => { 219 let timer; 220 const debounced = (...args) => { 221 clearTimeout(timer); 222 timer = setTimeout(() => func(...args), delay); 223 }; 224 debounced.cancel = () => { 225 clearTimeout(timer); 226 }; 227 return debounced; 228 }; 229 230 const fetchSuggestions = async (query) => { 231 if (!query) { 232 setSuggestions([]); 233 return; 234 } 235 try { 236 const res = await fetch( 237 `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5` 238 ); 239 if (!res.ok) throw new Error("Failed to fetch suggestions"); 240 const data = await res.json(); 241 setSuggestions(data.actors || []); 242 setAutocompleteActive(true); 243 } catch (error) { 244 console.error("Error fetching suggestions:", error); 245 setSuggestions([]); 246 } 247 }; 248 249 const debouncedFetchSuggestions = useRef(debounce(fetchSuggestions, 300)).current; 250 251 useEffect(() => { 252 if (!selectedSuggestion) { 253 debouncedFetchSuggestions(username); 254 } 255 }, [username, debouncedFetchSuggestions, selectedSuggestion]); 256 257 const renderTextResults = (analysisResult) => ( 258 <div> 259 <p><strong>{analysisResult.totalPosts}</strong> posts analyzed</p> 260 <p><strong>{analysisResult.postsWithImages}</strong> contain images</p> 261 <p><strong>{analysisResult.repliesWithImages}</strong> are replies</p> 262 <p><strong>{analysisResult.postsWithAltText}</strong> posts have alt text</p> 263 {analysisResult.postsWithImages > 0 ? ( 264 <h2>Score: {analysisResult.altTextPercentage.toFixed(2)}% {analysisResult.emoji}</h2> 265 ) : ( 266 <h2>No images found!</h2> 267 )} 268 </div> 269 ); 270 271 const handleSubmit = async (e) => { 272 e.preventDefault(); 273 setShowResults(true); 274 setLoading(true); 275 setShareButtonVisible(false); 276 setAnalysis(null); 277 setTextResults(null); 278 stopNeedleAnimation(); 279 startNeedleAnimation(); 280 281 try { 282 const did = await resolveHandleToDID(username); 283 setActorDID(did); 284 const serviceEndpoint = await fetchServiceEndpoint(did); 285 const records = await fetchRecordsForCollection(serviceEndpoint, did, "app.bsky.feed.post"); 286 setAllRecords(records); 287 288 const analysisResult = analyzePosts(records, excludeReplies, did); 289 setAnalysis(analysisResult); 290 setTextResults(renderTextResults(analysisResult)); 291 updateGauge(analysisResult.altTextPercentage); 292 setShareButtonVisible(true); 293 stopNeedleAnimation(); 294 } catch (error) { 295 setTextResults(<p style={{ color: 'red' }}>Error: {error.message}</p>); 296 updateGauge(0); 297 stopNeedleAnimation(); 298 } 299 setLoading(false); 300 }; 301 302 useEffect(() => { 303 if (allRecords.length > 0 && actorDID) { 304 const newAnalysis = analyzePosts(allRecords, excludeReplies, actorDID); 305 setAnalysis(newAnalysis); 306 setTextResults(renderTextResults(newAnalysis)); 307 updateGauge(newAnalysis.altTextPercentage); 308 } 309 }, [excludeReplies, allRecords, actorDID]); 310 311 const handleInputChange = (e) => { 312 setUsername(e.target.value); 313 if (selectedSuggestion && e.target.value !== selectedSuggestion) { 314 setSelectedSuggestion(''); 315 } 316 }; 317 318 return ( 319 <div className="alt-text-rating-tool"> 320 <div id="alt-text-rating-form" className="alt-card"> 321 <h1>Bluesky Alt Text Rating</h1> 322 <p>How consistently do you use alt text?</p> 323 <form className="search-bar" onSubmit={handleSubmit} autoComplete="off"> 324 <div style={{ position: 'relative' }}> 325 <input 326 type="text" 327 value={username} 328 onChange={handleInputChange} 329 placeholder="(e.g., user.bsky.social)" 330 required 331 /> 332 {autocompleteActive && suggestions.length > 0 && ( 333 <div className="autocomplete-items"> 334 {suggestions.map((actor, index) => ( 335 <div 336 key={actor.handle} 337 className={`autocomplete-item ${index === activeSuggestionIndex ? 'active' : ''}`} 338 onClick={() => { 339 setUsername(actor.handle); 340 setSelectedSuggestion(actor.handle); 341 setSuggestions([]); 342 setAutocompleteActive(false); 343 debouncedFetchSuggestions.cancel(); 344 }} 345 > 346 <img src={actor.avatar} alt={`${actor.handle}'s avatar`} /> 347 <span>{actor.handle}</span> 348 </div> 349 ))} 350 </div> 351 )} 352 </div> 353 <div className="action-row"> 354 <button className="analyze-button" type="submit">Analyze</button> 355 <button 356 className="share-button" 357 type="button" 358 onClick={() => window.open( 359 `https://bsky.app/intent/compose?text=${encodeURIComponent( 360 `My alt text rating score is ${analysis?.altTextPercentage?.toFixed(2)}% ${analysis?.emoji}\n\n${analysis?.totalPosts} posts analyzed,\n${analysis?.postsWithImages} contain images,\n${analysis?.postsWithAltText} have alt text...\n\nGet your Bluesky alt text rating here: https://cred.blue/alt-text` 361 )}`, '_blank' 362 )} 363 style={{ display: shareButtonVisible ? 'inline-block' : 'none' }} 364 > 365 Share Results 366 </button> 367 </div> 368 </form> 369 <div className="results" style={{ display: showResults ? 'block' : 'none' }}> 370 <div id="textResults">{textResults}</div> 371 <div className="gauge-container"> 372 <svg className="gauge-svg" viewBox="0 0 400 300"> 373 <path d="M50,300 A150,150 0 0,1 93.93,193.93 L200,300 Z" fill="#ff0000" /> 374 <path d="M93.93,193.93 A150,150 0 0,1 200,150 L200,300 Z" fill="#ff9900" /> 375 <path d="M200,150 A150,150 0 0,1 306.07,193.93 L200,300 Z" fill="#ffff66" /> 376 <path d="M306.07,193.93 A150,150 0 0,1 350,300 L200,300 Z" fill="#00cc00" /> 377 <circle cx="200" cy="300" r="10" fill="#000" /> 378 <g ref={needleGroupRef} className="needle-group" transform="rotate(0,200,300)"> 379 <line x1="200" y1="300" x2="50" y2="300" stroke="#000" strokeWidth="7" /> 380 </g> 381 </svg> 382 </div> 383 <div className="filters-container"> 384 <label className="checkbox-container"> 385 <input 386 type="checkbox" 387 checked={excludeReplies} 388 onChange={(e) => setExcludeReplies(e.target.checked)} 389 /> 390 <span className="checkbox-indicator"></span> 391 Exclude Replies 392 </label> 393 </div> 394 <button 395 className="full-analysis-button" 396 type="button" 397 onClick={() => navigate(`/${username}`)} 398 > 399 View Full Analysis 400 </button> 401 <p> 402 <a href="https://bsky.app/settings/accessibility" target="_blank" rel="noreferrer"> 403 Change your Bluesky alt text settings 404 </a> 405 </p> 406 <p> 407 <a href="https://bsky.app/profile/cred.blue" target="_blank" rel="noreferrer"> 408 Discover more tools: @cred.blue 409 </a> 410 </p> 411 </div> 412 </div> 413 <div id="extra-info" className="alt-card"> 414 <div className="resources"> 415 <h3>Learn more about alt text:</h3> 416 <ul> 417 <li> 418 <a href="https://www.section508.gov/create/alternative-text/" target="_blank" rel="noreferrer"> 419 Authoring Meaningful Alternative Text 420 </a> 421 </li> 422 <li> 423 <a href="https://accessibility.huit.harvard.edu/describe-content-images" target="_blank" rel="noreferrer"> 424 Write helpful Alt Text to describe images 425 </a> 426 </li> 427 </ul> 428 </div> 429 </div> 430 </div> 431 ); 432}; 433 434export default AltTextRatingTool;