Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
10 kB · 324 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325import { html, render } from "/js/lib/lit-html.js";import { Component } from "/js/components/component.js";import { scrollLocks } from "/js/scrollLocks.js";import { displayRelativeTime } from "/js/utils.js";import { closeWithAnimation } from "/js/dialogHelpers.js";import { enableDragToDismiss } from "/js/dragHelpers.js";import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js";import { confirmModal } from "/js/modals/confirm.modal.js";import { showToast } from "/js/toasts.js";import "/js/components/app-icon.js";import { getLocalRefsFromDraft, getImagesFromDraftPost,} from "/js/dataHelpers.js";import { getDraftDeviceId } from "/js/drafts.js";import { parseGifFromUrl } from "/js/embedHelpers.js";import "/js/components/infinite-scroll-container.js";
function draftTagTemplate(name, label, { warning = false } = {}) { return html`<span class="draft-item-tag ${warning ? "draft-item-tag-warning" : ""}" data-testid="draft-item-tag-${name}" >${label}</span >`;}
function draftMediaTemplate({ images, gif, video }) { const thumbs = []; for (const image of images) { if (image.previewUrl) { thumbs.push( html`<img class="draft-item-thumb" src=${image.previewUrl} alt=${image.alt ?? ""} />`, ); } } if (gif) { thumbs.push( html`<img class="draft-item-thumb" src=${gif.url} alt=${gif.alt} />`, ); } if (video?.exists) { thumbs.push( html`<div class="draft-item-thumb draft-item-video-placeholder"> <app-icon icon="play"></app-icon> </div>`, ); } if (thumbs.length === 0) { return ""; } return html`<div class="draft-item-media" data-testid="draft-item-media"> ${thumbs} </div>`;}
function draftItemTemplate({ draftView, onSelect, onDelete }) { const posts = draftView.posts; const firstPost = posts[0] ?? {}; const images = getImagesFromDraftPost(firstPost); const video = firstPost.embedVideos?.[0] ?? null; const gif = parseGifFromUrl(firstPost.embedExternals?.[0]?.uri ?? ""); const isOriginatingDevice = draftView.draft.deviceId === getDraftDeviceId(); const hasMissingMedia = posts.some( (draftPost) => getImagesFromDraftPost(draftPost).some((image) => !image.exists) || draftPost.embedVideos?.[0]?.exists === false, ); const hasQuotes = posts.some( (draftPost) => (draftPost.embedRecords?.length ?? 0) > 0, ); const replyCount = posts.length - 1; const showForeignMediaTag = !isOriginatingDevice && hasMissingMedia; const showMissingMediaTag = isOriginatingDevice && hasMissingMedia; return html` <div class="draft-item" data-testid="draft-item" role="button" tabindex="0" @click=${() => onSelect(draftView)} @keydown=${(e) => { if (e.key === "Enter") { onSelect(draftView); } }} > <div class="draft-item-main"> <div class="draft-item-timestamp"> ${displayRelativeTime(draftView.updatedAt)} </div> ${firstPost.text ? html`<div class="draft-item-text">${firstPost.text}</div>` : ""} ${isOriginatingDevice ? draftMediaTemplate({ images, gif, video }) : ""} <div class="draft-item-footer"> ${showForeignMediaTag ? draftTagTemplate( "foreign-media", "Media stored on another device", ) : ""} ${showMissingMediaTag ? draftTagTemplate("missing-media", "Missing media", { warning: true, }) : ""} ${hasQuotes ? draftTagTemplate("quote", "Quote post") : ""} ${replyCount > 0 ? draftTagTemplate( "thread", replyCount === 1 ? "1 more post" : `${replyCount} more posts`, ) : ""} </div> </div> <div class="draft-item-side"> <button class="icon-button draft-item-delete" data-testid="draft-item-delete" aria-label="Delete draft" @click=${(e) => { e.stopPropagation(); onDelete(draftView); }} > <app-icon icon="delete-bin-line"></app-icon> </button> </div> </div> `;}
class DraftsDialog extends Component { connectedCallback() { if (this.initialized) { return; } this.setAttribute("data-dialog-wrapper", ""); this.scrollLock = null; this.state = new ReactiveStore("drafts-dialog"); this.state.$loadError = new Signal.State(false); this.state.$isLoadingMore = new Signal.State(false); this.innerHTML = ""; this._disposeEffect = effect(() => { this.render(); }); this._loadInitial(); this.initialized = true; }
disconnectedCallback() { this._disposeEffect?.(); this._disposeEffect = null; }
async _loadInitial() { if (untrack(() => this.dataLayer.derived.$hydratedDrafts.get()) !== null) { return; } try { await this.dataLayer.requests.loadDrafts({ reload: true }); } catch (error) { console.error("Failed to load drafts", error); this.state.$loadError.set(true); } }
async _loadMore(resume) { if (untrack(() => this.state.$isLoadingMore.get())) { resume(); return; } this.state.$isLoadingMore.set(true); try { await this.dataLayer.requests.loadDrafts(); } catch (error) { console.error("Failed to load more drafts", error); } finally { this.state.$isLoadingMore.set(false); resume(); } }
_onSelect(draftView) { // Start the close animation, but restore immediately without waiting for it this.close(); this.dispatchEvent( new CustomEvent("draft-selected", { detail: { draftView } }), ); }
async _onDelete(draftView) { const confirmed = await confirmModal( "This draft will be permanently deleted.", { title: "Discard draft?", confirmButtonStyle: "danger", confirmButtonText: "Delete", }, ); if (!confirmed) return; try { await this.dataLayer.mutations.deleteDraft({ draftId: draftView.id, localRefs: getLocalRefsFromDraft(draftView.draft), }); this.dispatchEvent( new CustomEvent("draft-deleted", { detail: { draftId: draftView.id }, }), ); } catch (error) { console.error("Failed to delete draft", error); showToast("Failed to delete draft", { style: "error" }); } }
render() { const data = this.dataLayer?.derived.$hydratedDrafts.get() ?? null; const draftViews = data?.drafts ?? null; const cursor = data?.cursor ?? null; const loadError = this.state.$loadError.get(); const isLoadingMore = this.state.$isLoadingMore.get(); render( html` <dialog class="bottom-sheet bottom-sheet-stacked no-handle drafts-dialog" data-testid="drafts-dialog" @click=${(event) => { if (event.target === event.currentTarget) { this.close(); } }} @cancel=${(event) => { event.preventDefault(); this.close(); }} @close=${() => { this.scrollLock?.release(); this.scrollLock = null; this.dispatchEvent(new CustomEvent("dialog-closed")); }} > <div class="drafts-dialog-content"> <div class="drafts-dialog-header"> <button class="text-pill-button drafts-dialog-back" data-testid="drafts-dialog-back" @click=${() => this.close()} > Back </button> <h2 class="drafts-dialog-title" data-testid="modal-title"> Drafts </h2> </div> <div class="drafts-dialog-list" data-testid="drafts-dialog-list"> ${loadError ? html`<div class="drafts-dialog-message" data-testid="error-state" > Failed to load drafts </div>` : draftViews === null ? html`<div class="drafts-dialog-message"> <div class="loading-spinner"></div> </div>` : draftViews.length === 0 ? html`<div class="drafts-dialog-message" data-testid="empty-state" > No drafts yet </div>` : html`<infinite-scroll-container ?disabled=${cursor === null} @load-more=${(e) => this._loadMore(e.detail.resume)} > ${draftViews.map((draftView) => draftItemTemplate({ draftView, onSelect: (selected) => this._onSelect(selected), onDelete: (selected) => this._onDelete(selected), }), )} ${isLoadingMore ? html`<div class="drafts-dialog-message"> <div class="loading-spinner"></div> </div>` : ""} </infinite-scroll-container>`} </div> </div> </dialog> `, this, ); }
open() { this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector("dialog"); if (dialog?.open) return; dialog.showModal(); enableDragToDismiss(dialog, { onDismiss: () => this.close(), scrollContainer: this.querySelector(".drafts-dialog-list"), dragHandle: this.querySelector(".drafts-dialog-header"), ignoreTouchTarget: (element) => element.closest("button") !== null, disableWhenKeyboardOpen: true, }); }
close() { return closeWithAnimation(this.querySelector("dialog")); }}
DraftsDialog.register();