forked from
grain.social/grain-pwa
WIP PWA for Grain
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);