WIP PWA for Grain
at main 402 lines 12 kB view raw
1import { LitElement, html, css } from 'lit'; 2import { grainApi } from '../../services/grain-api.js'; 3import { mutations } from '../../services/mutations.js'; 4import { auth } from '../../services/auth.js'; 5import { recordCache } from '../../services/record-cache.js'; 6import '../molecules/grain-comment.js'; 7import '../molecules/grain-comment-input.js'; 8import '../atoms/grain-spinner.js'; 9import '../atoms/grain-close-button.js'; 10 11export class GrainCommentSheet extends LitElement { 12 static properties = { 13 open: { type: Boolean, reflect: true }, 14 galleryUri: { type: String }, 15 focusPhotoUri: { type: String }, 16 focusPhotoUrl: { type: String }, 17 _comments: { state: true }, 18 _loading: { state: true }, 19 _loadingMore: { state: true }, 20 _posting: { state: true }, 21 _inputValue: { state: true }, 22 _replyToUri: { state: true }, 23 _replyToHandle: { state: true }, 24 _pageInfo: { state: true }, 25 _totalCount: { state: true }, 26 _focusPhotoUri: { state: true }, 27 _focusPhotoUrl: { state: true } 28 }; 29 30 static styles = css` 31 :host { 32 display: none; 33 } 34 :host([open]) { 35 display: block; 36 } 37 .overlay { 38 position: fixed; 39 inset: 0; 40 background: rgba(0, 0, 0, 0.5); 41 z-index: 1000; 42 } 43 .sheet-container { 44 position: fixed; 45 bottom: calc(57px + env(safe-area-inset-bottom, 0px)); 46 left: 0; 47 right: 0; 48 display: flex; 49 justify-content: center; 50 z-index: 1001; 51 animation: slideUp 0.2s ease-out; 52 } 53 .sheet { 54 width: 100%; 55 max-width: var(--feed-max-width); 56 max-height: 70vh; 57 background: var(--color-bg-primary); 58 border-radius: 12px 12px 0 0; 59 border: 1px solid var(--color-border); 60 border-bottom: none; 61 display: flex; 62 flex-direction: column; 63 } 64 @keyframes slideUp { 65 from { transform: translateY(100%); } 66 to { transform: translateY(0); } 67 } 68 .header { 69 display: flex; 70 align-items: center; 71 justify-content: center; 72 padding: var(--space-sm) var(--space-md); 73 border-bottom: 1px solid var(--color-border); 74 position: relative; 75 } 76 .header h2 { 77 margin: 0; 78 font-size: var(--font-size-md); 79 font-weight: var(--font-weight-semibold); 80 } 81 grain-close-button { 82 position: absolute; 83 right: var(--space-sm); 84 } 85 .comments-list { 86 flex: 1; 87 overflow-y: auto; 88 padding: var(--space-sm) var(--space-md); 89 -webkit-overflow-scrolling: touch; 90 } 91 .load-more { 92 display: flex; 93 justify-content: center; 94 padding: var(--space-sm); 95 } 96 .load-more-btn { 97 background: none; 98 border: none; 99 color: var(--color-text-secondary); 100 font-size: var(--font-size-sm); 101 cursor: pointer; 102 padding: var(--space-xs) var(--space-sm); 103 } 104 .load-more-btn:hover { 105 color: var(--color-text-primary); 106 } 107 .empty { 108 text-align: center; 109 padding: var(--space-xl); 110 color: var(--color-text-secondary); 111 font-size: var(--font-size-sm); 112 } 113 .loading { 114 display: flex; 115 justify-content: center; 116 padding: var(--space-xl); 117 } 118 grain-comment-input { 119 flex-shrink: 0; 120 } 121 `; 122 123 constructor() { 124 super(); 125 this.open = false; 126 this.galleryUri = ''; 127 this._comments = []; 128 this._loading = false; 129 this._loadingMore = false; 130 this._posting = false; 131 this._inputValue = ''; 132 this._replyToUri = null; 133 this._replyToHandle = null; 134 this._pageInfo = { hasNextPage: false, endCursor: null }; 135 this._totalCount = 0; 136 this._focusPhotoUri = null; 137 this._focusPhotoUrl = null; 138 } 139 140 updated(changedProps) { 141 if (changedProps.has('open') && this.open && this.galleryUri) { 142 this.#loadComments(); 143 this._focusPhotoUri = this.focusPhotoUri || null; 144 this._focusPhotoUrl = this.focusPhotoUrl || null; 145 } 146 } 147 148 async #loadComments() { 149 this._loading = true; 150 this._comments = []; 151 152 try { 153 const result = await grainApi.getComments(this.galleryUri, { first: 20 }); 154 this._comments = this.#organizeComments(result.comments); 155 this._pageInfo = result.pageInfo; 156 this._totalCount = result.totalCount; 157 } catch (err) { 158 console.error('Failed to load comments:', err); 159 } finally { 160 this._loading = false; 161 } 162 } 163 164 async #loadMore() { 165 if (this._loadingMore || !this._pageInfo.hasNextPage) return; 166 167 this._loadingMore = true; 168 try { 169 const result = await grainApi.getComments(this.galleryUri, { 170 first: 20, 171 after: this._pageInfo.endCursor 172 }); 173 const newComments = this.#organizeComments(result.comments); 174 this._comments = [...this._comments, ...newComments]; 175 this._pageInfo = result.pageInfo; 176 } catch (err) { 177 console.error('Failed to load more comments:', err); 178 } finally { 179 this._loadingMore = false; 180 } 181 } 182 183 #organizeComments(comments) { 184 // Group replies under their parents 185 const roots = []; 186 const replyMap = new Map(); 187 188 comments.forEach(comment => { 189 if (comment.replyToUri) { 190 const replies = replyMap.get(comment.replyToUri) || []; 191 replies.push({ ...comment, isReply: true }); 192 replyMap.set(comment.replyToUri, replies); 193 } else { 194 roots.push(comment); 195 } 196 }); 197 198 // Flatten: root, then its replies 199 const organized = []; 200 roots.forEach(root => { 201 organized.push(root); 202 const replies = replyMap.get(root.uri) || []; 203 replies.forEach(reply => organized.push(reply)); 204 }); 205 206 return organized; 207 } 208 209 #handleClose() { 210 // Blur active element first to release iOS focus/scroll context 211 document.activeElement?.blur(); 212 213 // Small delay to let iOS finish processing touch before hiding 214 requestAnimationFrame(() => { 215 this.open = false; 216 this._replyToUri = null; 217 this._replyToHandle = null; 218 this._inputValue = ''; 219 this.dispatchEvent(new CustomEvent('close')); 220 }); 221 } 222 223 #handleOverlayClick() { 224 this.#handleClose(); 225 } 226 227 #handleInputChange(e) { 228 this._inputValue = e.detail.value; 229 } 230 231 async #handleSend(e) { 232 const text = e.detail.value; 233 if (!text || this._posting) return; 234 235 this._posting = true; 236 try { 237 const commentUri = await mutations.createComment( 238 this.galleryUri, 239 text, 240 this._replyToUri, 241 this._focusPhotoUri 242 ); 243 244 // Add new comment to list 245 const newComment = { 246 uri: commentUri, 247 text, 248 createdAt: new Date().toISOString(), 249 handle: auth.user?.handle || '', 250 displayName: auth.user?.displayName || '', 251 avatarUrl: auth.user?.avatar?.url || '', 252 replyToUri: this._replyToUri, 253 isReply: !!this._replyToUri, 254 focusImageUrl: this._focusPhotoUrl || null, 255 focusImageAlt: '' 256 }; 257 258 if (this._replyToUri) { 259 // Insert after parent 260 const parentIndex = this._comments.findIndex(c => c.uri === this._replyToUri); 261 if (parentIndex >= 0) { 262 // Find last reply of this parent 263 let insertIndex = parentIndex + 1; 264 while (insertIndex < this._comments.length && this._comments[insertIndex].isReply) { 265 insertIndex++; 266 } 267 this._comments = [ 268 ...this._comments.slice(0, insertIndex), 269 newComment, 270 ...this._comments.slice(insertIndex) 271 ]; 272 } else { 273 this._comments = [...this._comments, newComment]; 274 } 275 } else { 276 this._comments = [...this._comments, newComment]; 277 } 278 279 this._totalCount++; 280 281 // Update comment count in cache 282 recordCache.set(this.galleryUri, { 283 commentCount: this._totalCount 284 }); 285 286 // Clear input 287 this._inputValue = ''; 288 this._replyToUri = null; 289 this._replyToHandle = null; 290 this._focusPhotoUri = null; 291 this._focusPhotoUrl = null; 292 this.shadowRoot.querySelector('grain-comment-input')?.clear(); 293 } catch (err) { 294 console.error('Failed to post comment:', err); 295 } finally { 296 this._posting = false; 297 } 298 } 299 300 #handleReply(e) { 301 const { uri, handle } = e.detail; 302 this._replyToUri = uri; 303 this._replyToHandle = handle; 304 this._inputValue = `@${handle} `; 305 306 // Scroll comment into view 307 const commentEl = this.shadowRoot.querySelector(`grain-comment[uri="${uri}"]`); 308 commentEl?.scrollIntoView({ behavior: 'smooth', block: 'start' }); 309 310 // Focus input 311 this.shadowRoot.querySelector('grain-comment-input')?.focus(); 312 } 313 314 #handleClearFocus() { 315 this._focusPhotoUri = null; 316 this._focusPhotoUrl = null; 317 } 318 319 async #handleDelete(e) { 320 const { uri } = e.detail; 321 322 try { 323 await mutations.deleteComment(uri); 324 325 // Remove from list 326 this._comments = this._comments.filter(c => c.uri !== uri); 327 this._totalCount--; 328 329 // Update comment count in cache 330 recordCache.set(this.galleryUri, { 331 commentCount: this._totalCount 332 }); 333 } catch (err) { 334 console.error('Failed to delete comment:', err); 335 } 336 } 337 338 render() { 339 const userAvatarUrl = auth.user?.avatar?.url || ''; 340 341 return html` 342 <div class="overlay" @click=${this.#handleOverlayClick}></div> 343 <div class="sheet-container"> 344 <div class="sheet"> 345 <div class="header"> 346 <h2>Comments</h2> 347 <grain-close-button @close=${this.#handleClose}></grain-close-button> 348 </div> 349 350 <div class="comments-list"> 351 ${this._loading ? html` 352 <div class="loading"><grain-spinner></grain-spinner></div> 353 ` : this._comments.length === 0 ? html` 354 <div class="empty">No comments yet. Be the first!</div> 355 ` : html` 356 ${this._pageInfo.hasNextPage ? html` 357 <div class="load-more"> 358 ${this._loadingMore ? html` 359 <grain-spinner></grain-spinner> 360 ` : html` 361 <button class="load-more-btn" @click=${this.#loadMore}> 362 Load earlier comments 363 </button> 364 `} 365 </div> 366 ` : ''} 367 ${this._comments.map(comment => html` 368 <grain-comment 369 uri=${comment.uri} 370 handle=${comment.handle} 371 displayName=${comment.displayName} 372 avatarUrl=${comment.avatarUrl} 373 text=${comment.text} 374 .facets=${comment.facets || []} 375 createdAt=${comment.createdAt} 376 ?is-reply=${comment.isReply} 377 ?isOwner=${comment.handle === auth.user?.handle} 378 focusImageUrl=${comment.focusImageUrl || ''} 379 focusImageAlt=${comment.focusImageAlt || ''} 380 @reply=${this.#handleReply} 381 @delete=${this.#handleDelete} 382 ></grain-comment> 383 `)} 384 `} 385 </div> 386 387 <grain-comment-input 388 avatarUrl=${userAvatarUrl} 389 .value=${this._inputValue} 390 ?loading=${this._posting} 391 focusPhotoUrl=${this._focusPhotoUrl || ''} 392 @input-change=${this.#handleInputChange} 393 @send=${this.#handleSend} 394 @clear-focus=${this.#handleClearFocus} 395 ></grain-comment-input> 396 </div> 397 </div> 398 `; 399 } 400} 401 402customElements.define('grain-comment-sheet', GrainCommentSheet);