import { LitElement, html, css } from 'lit'; import { grainApi } from '../../services/grain-api.js'; import { mutations } from '../../services/mutations.js'; import { auth } from '../../services/auth.js'; import { recordCache } from '../../services/record-cache.js'; import '../molecules/grain-comment.js'; import '../molecules/grain-comment-input.js'; import '../atoms/grain-spinner.js'; import '../atoms/grain-close-button.js'; export class GrainCommentSheet extends LitElement { static properties = { open: { type: Boolean, reflect: true }, galleryUri: { type: String }, focusPhotoUri: { type: String }, focusPhotoUrl: { type: String }, _comments: { state: true }, _loading: { state: true }, _loadingMore: { state: true }, _posting: { state: true }, _inputValue: { state: true }, _replyToUri: { state: true }, _replyToHandle: { state: true }, _pageInfo: { state: true }, _totalCount: { state: true }, _focusPhotoUri: { state: true }, _focusPhotoUrl: { state: true } }; static styles = css` :host { display: none; } :host([open]) { display: block; } .overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); z-index: 1000; } .sheet-container { position: fixed; bottom: calc(57px + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; display: flex; justify-content: center; z-index: 1001; animation: slideUp 0.2s ease-out; } .sheet { width: 100%; max-width: var(--feed-max-width); max-height: 70vh; background: var(--color-bg-primary); border-radius: 12px 12px 0 0; border: 1px solid var(--color-border); border-bottom: none; display: flex; flex-direction: column; } @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } .header { display: flex; align-items: center; justify-content: center; padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--color-border); position: relative; } .header h2 { margin: 0; font-size: var(--font-size-md); font-weight: var(--font-weight-semibold); } grain-close-button { position: absolute; right: var(--space-sm); } .comments-list { flex: 1; overflow-y: auto; padding: var(--space-sm) var(--space-md); -webkit-overflow-scrolling: touch; } .load-more { display: flex; justify-content: center; padding: var(--space-sm); } .load-more-btn { background: none; border: none; color: var(--color-text-secondary); font-size: var(--font-size-sm); cursor: pointer; padding: var(--space-xs) var(--space-sm); } .load-more-btn:hover { color: var(--color-text-primary); } .empty { text-align: center; padding: var(--space-xl); color: var(--color-text-secondary); font-size: var(--font-size-sm); } .loading { display: flex; justify-content: center; padding: var(--space-xl); } grain-comment-input { flex-shrink: 0; } `; constructor() { super(); this.open = false; this.galleryUri = ''; this._comments = []; this._loading = false; this._loadingMore = false; this._posting = false; this._inputValue = ''; this._replyToUri = null; this._replyToHandle = null; this._pageInfo = { hasNextPage: false, endCursor: null }; this._totalCount = 0; this._focusPhotoUri = null; this._focusPhotoUrl = null; } updated(changedProps) { if (changedProps.has('open') && this.open && this.galleryUri) { this.#loadComments(); this._focusPhotoUri = this.focusPhotoUri || null; this._focusPhotoUrl = this.focusPhotoUrl || null; } } async #loadComments() { this._loading = true; this._comments = []; try { const result = await grainApi.getComments(this.galleryUri, { first: 20 }); this._comments = this.#organizeComments(result.comments); this._pageInfo = result.pageInfo; this._totalCount = result.totalCount; } catch (err) { console.error('Failed to load comments:', err); } finally { this._loading = false; } } async #loadMore() { if (this._loadingMore || !this._pageInfo.hasNextPage) return; this._loadingMore = true; try { const result = await grainApi.getComments(this.galleryUri, { first: 20, after: this._pageInfo.endCursor }); const newComments = this.#organizeComments(result.comments); this._comments = [...this._comments, ...newComments]; this._pageInfo = result.pageInfo; } catch (err) { console.error('Failed to load more comments:', err); } finally { this._loadingMore = false; } } #organizeComments(comments) { // Group replies under their parents const roots = []; const replyMap = new Map(); comments.forEach(comment => { if (comment.replyToUri) { const replies = replyMap.get(comment.replyToUri) || []; replies.push({ ...comment, isReply: true }); replyMap.set(comment.replyToUri, replies); } else { roots.push(comment); } }); // Flatten: root, then its replies const organized = []; roots.forEach(root => { organized.push(root); const replies = replyMap.get(root.uri) || []; replies.forEach(reply => organized.push(reply)); }); return organized; } #handleClose() { // Blur active element first to release iOS focus/scroll context document.activeElement?.blur(); // Small delay to let iOS finish processing touch before hiding requestAnimationFrame(() => { this.open = false; this._replyToUri = null; this._replyToHandle = null; this._inputValue = ''; this.dispatchEvent(new CustomEvent('close')); }); } #handleOverlayClick() { this.#handleClose(); } #handleInputChange(e) { this._inputValue = e.detail.value; } async #handleSend(e) { const text = e.detail.value; if (!text || this._posting) return; this._posting = true; try { const commentUri = await mutations.createComment( this.galleryUri, text, this._replyToUri, this._focusPhotoUri ); // Add new comment to list const newComment = { uri: commentUri, text, createdAt: new Date().toISOString(), handle: auth.user?.handle || '', displayName: auth.user?.displayName || '', avatarUrl: auth.user?.avatar?.url || '', replyToUri: this._replyToUri, isReply: !!this._replyToUri, focusImageUrl: this._focusPhotoUrl || null, focusImageAlt: '' }; if (this._replyToUri) { // Insert after parent const parentIndex = this._comments.findIndex(c => c.uri === this._replyToUri); if (parentIndex >= 0) { // Find last reply of this parent let insertIndex = parentIndex + 1; while (insertIndex < this._comments.length && this._comments[insertIndex].isReply) { insertIndex++; } this._comments = [ ...this._comments.slice(0, insertIndex), newComment, ...this._comments.slice(insertIndex) ]; } else { this._comments = [...this._comments, newComment]; } } else { this._comments = [...this._comments, newComment]; } this._totalCount++; // Update comment count in cache recordCache.set(this.galleryUri, { commentCount: this._totalCount }); // Clear input this._inputValue = ''; this._replyToUri = null; this._replyToHandle = null; this._focusPhotoUri = null; this._focusPhotoUrl = null; this.shadowRoot.querySelector('grain-comment-input')?.clear(); } catch (err) { console.error('Failed to post comment:', err); } finally { this._posting = false; } } #handleReply(e) { const { uri, handle } = e.detail; this._replyToUri = uri; this._replyToHandle = handle; this._inputValue = `@${handle} `; // Scroll comment into view const commentEl = this.shadowRoot.querySelector(`grain-comment[uri="${uri}"]`); commentEl?.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Focus input this.shadowRoot.querySelector('grain-comment-input')?.focus(); } #handleClearFocus() { this._focusPhotoUri = null; this._focusPhotoUrl = null; } async #handleDelete(e) { const { uri } = e.detail; try { await mutations.deleteComment(uri); // Remove from list this._comments = this._comments.filter(c => c.uri !== uri); this._totalCount--; // Update comment count in cache recordCache.set(this.galleryUri, { commentCount: this._totalCount }); } catch (err) { console.error('Failed to delete comment:', err); } } render() { const userAvatarUrl = auth.user?.avatar?.url || ''; return html`

Comments

${this._loading ? html`
` : this._comments.length === 0 ? html`
No comments yet. Be the first!
` : html` ${this._pageInfo.hasNextPage ? html`
${this._loadingMore ? html` ` : html` `}
` : ''} ${this._comments.map(comment => html` `)} `}
`; } } customElements.define('grain-comment-sheet', GrainCommentSheet);