mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-27 22:34:51 -06:00
feat(attachments): inline chip previews (image/pdf thumbnail, audio player, text snippet)
- core/thumbnails.py + GET .../attachments/{id}/thumbnail: server-rendered PNG
thumbnails (image downscale; pdf first page via pypdfium2). Extracted a shared
ownership-gated blob resolver used by both get_content and the thumbnail route
- buildAttachmentPreview (composer_attachments.js): image/pdf -> thumbnail,
audio -> <audio> player, text -> lazy snippet; reused by the composer chips and
the sent-message pills (interactive.js). Cookie auth, so direct media src works
- chip kind icons now cover pdf/audio; the upload swap adopts the server's
authoritative kind for styling + icon + preview
- chat.css preview styling; tests for make_thumbnail
This commit is contained in:
@@ -36,7 +36,9 @@ async def _stub(_request: Request) -> JSONResponse:
|
||||
|
||||
|
||||
def _attach() -> AttachmentHandlers:
|
||||
return AttachmentHandlers(upload=_stub, list=_stub, get_content=_stub, delete=_stub)
|
||||
return AttachmentHandlers(
|
||||
upload=_stub, list=_stub, get_content=_stub, thumbnail=_stub, delete=_stub
|
||||
)
|
||||
|
||||
|
||||
def _route_paths(routes: list[Any]) -> list[tuple[str, frozenset[str]]]:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for attachment thumbnail generation (image downscale + pdf first page)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from turnstone.core.thumbnails import make_thumbnail
|
||||
|
||||
PNG_1x1 = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf"
|
||||
b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def _minimal_pdf(text: str = "Hi") -> bytes:
|
||||
stream = b"BT /F1 24 Tf 20 60 Td (" + text.encode("latin-1") + b") Tj ET"
|
||||
objs = [
|
||||
b"<</Type/Catalog/Pages 2 0 R>>",
|
||||
b"<</Type/Pages/Kids[3 0 R]/Count 1>>",
|
||||
b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 144]"
|
||||
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>",
|
||||
b"<</Length %d>>\nstream\n%s\nendstream" % (len(stream), stream),
|
||||
b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>",
|
||||
]
|
||||
pdf = b"%PDF-1.4\n"
|
||||
offsets = []
|
||||
for i, obj in enumerate(objs, 1):
|
||||
offsets.append(len(pdf))
|
||||
pdf += b"%d 0 obj\n%s\nendobj\n" % (i, obj)
|
||||
xref = len(pdf)
|
||||
pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
|
||||
for off in offsets:
|
||||
pdf += b"%010d 00000 n \n" % off
|
||||
pdf += b"trailer\n<</Size %d/Root 1 0 R>>\nstartxref\n%d\n%%%%EOF" % (len(objs) + 1, xref)
|
||||
return pdf
|
||||
|
||||
|
||||
class TestMakeThumbnail:
|
||||
def test_image_thumbnail_is_png(self) -> None:
|
||||
out = make_thumbnail(PNG_1x1, "image")
|
||||
assert out is not None and out[:8] == _PNG_MAGIC
|
||||
|
||||
def test_pdf_thumbnail_is_png(self) -> None:
|
||||
out = make_thumbnail(_minimal_pdf(), "pdf")
|
||||
assert out is not None and out[:8] == _PNG_MAGIC
|
||||
|
||||
def test_audio_has_no_thumbnail(self) -> None:
|
||||
assert make_thumbnail(b"RIFFfake", "audio") is None
|
||||
|
||||
def test_garbage_image_returns_none(self) -> None:
|
||||
assert make_thumbnail(b"not an image", "image") is None
|
||||
@@ -483,6 +483,7 @@ class AttachmentHandlers:
|
||||
upload: Handler # POST {prefix}/{ws_id}/attachments
|
||||
list: Handler # GET {prefix}/{ws_id}/attachments
|
||||
get_content: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/content
|
||||
thumbnail: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/thumbnail
|
||||
delete: Handler # DELETE {prefix}/{ws_id}/attachments/{attachment_id}
|
||||
|
||||
|
||||
@@ -630,6 +631,13 @@ def register_session_routes(
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
f"{p}/{{ws_id}}/attachments/{{attachment_id}}/thumbnail",
|
||||
a.thumbnail,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
f"{p}/{{ws_id}}/attachments/{{attachment_id}}",
|
||||
@@ -3816,10 +3824,18 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
|
||||
]
|
||||
return JSONResponse({"attachments": rows})
|
||||
|
||||
async def get_content(request: Request) -> Response:
|
||||
import asyncio
|
||||
async def _resolve_served_blob(
|
||||
request: Request,
|
||||
) -> tuple[bytes, str, str, str] | Response:
|
||||
"""Gate + resolve an attachment blob for serving (content or thumbnail).
|
||||
|
||||
from starlette.responses import Response as _Response
|
||||
Returns ``(body, kind, mime, filename)`` or an error ``Response``.
|
||||
Pending (staged) blobs serve from the buffer scoped to the uploader;
|
||||
committed blobs serve from the store gated by ownership — the requester
|
||||
(already gated to own ``ws_id``) must have a turn whose ref-list names the
|
||||
id. Cross-user / cross-ws / unreferenced → 404 so existence doesn't leak.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from turnstone.core.attachment_buffer import get_attachment_buffer
|
||||
from turnstone.core.memory import attachment_referenced_in_ws, get_attachment
|
||||
@@ -3834,38 +3850,38 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
|
||||
user_id, err = await _resolve_owner(request, ws_id)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Pending (staged) blobs serve straight from the buffer, scoped to the
|
||||
# uploader. Committed blobs serve from the store, gated by ownership:
|
||||
# the requester (already gated to own ``ws_id``) must have a turn whose
|
||||
# ref-list names the id. Cross-user / cross-ws / unreferenced → 404 so
|
||||
# existence doesn't leak.
|
||||
kind: str
|
||||
stored_mime: str
|
||||
filename: str
|
||||
staged = get_attachment_buffer().get(attachment_id, ws_id=ws_id, user_id=user_id)
|
||||
if staged is not None:
|
||||
body: bytes = staged.content
|
||||
kind = staged.kind
|
||||
stored_mime = staged.mime_type or "application/octet-stream"
|
||||
filename = staged.filename or "attachment"
|
||||
else:
|
||||
# Both committed-blob gates are sync DB I/O — the ref check is an
|
||||
# unbounded ws-scoped LIKE scan (O(turns-in-ws)) run on every
|
||||
# committed-image request, so keep it off the event loop. Matches
|
||||
# the asyncio.to_thread convention used throughout this module.
|
||||
row = await asyncio.to_thread(get_attachment, attachment_id)
|
||||
if not row or not await asyncio.to_thread(
|
||||
attachment_referenced_in_ws, attachment_id, ws_id
|
||||
):
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
body = row.get("content") or b""
|
||||
kind = row.get("kind") or ""
|
||||
stored_mime = row.get("mime_type") or "application/octet-stream"
|
||||
filename = str(row.get("filename") or "attachment")
|
||||
return (
|
||||
staged.content,
|
||||
staged.kind,
|
||||
staged.mime_type or "application/octet-stream",
|
||||
staged.filename or "attachment",
|
||||
)
|
||||
# Committed-blob gates are sync DB I/O — the ref check is an unbounded
|
||||
# ws-scoped LIKE scan (O(turns-in-ws)), so keep it off the event loop.
|
||||
row = await asyncio.to_thread(get_attachment, attachment_id)
|
||||
if not row or not await asyncio.to_thread(
|
||||
attachment_referenced_in_ws, attachment_id, ws_id
|
||||
):
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return (
|
||||
row.get("content") or b"",
|
||||
row.get("kind") or "",
|
||||
row.get("mime_type") or "application/octet-stream",
|
||||
str(row.get("filename") or "attachment"),
|
||||
)
|
||||
|
||||
async def get_content(request: Request) -> Response:
|
||||
from starlette.responses import Response as _Response
|
||||
|
||||
resolved = await _resolve_served_blob(request)
|
||||
if not isinstance(resolved, tuple):
|
||||
return resolved
|
||||
body, kind, stored_mime, filename = resolved
|
||||
# Force text/plain for text kinds — avoids same-origin HTML/SVG
|
||||
# rendering if a user uploaded an HTML-ish text file. Images
|
||||
# keep their sniffed MIME (allowlist is strict: png/jpeg/gif/webp).
|
||||
# rendering if a user uploaded an HTML-ish text file. Images keep their
|
||||
# sniffed MIME (allowlist is strict: png/jpeg/gif/webp).
|
||||
response_mime = "text/plain; charset=utf-8" if kind == "text" else stored_mime
|
||||
safe_name = filename.replace('"', "").replace("\r", "").replace("\n", "")
|
||||
headers = {
|
||||
@@ -3876,6 +3892,32 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
|
||||
}
|
||||
return _Response(body, media_type=response_mime, headers=headers)
|
||||
|
||||
async def get_thumbnail(request: Request) -> Response:
|
||||
import asyncio
|
||||
|
||||
from starlette.responses import Response as _Response
|
||||
|
||||
from turnstone.core.thumbnails import make_thumbnail
|
||||
|
||||
resolved = await _resolve_served_blob(request)
|
||||
if not isinstance(resolved, tuple):
|
||||
return resolved
|
||||
body, kind, _mime, _filename = resolved
|
||||
if kind not in ("image", "pdf"):
|
||||
return JSONResponse({"error": "no thumbnail for this attachment kind"}, status_code=415)
|
||||
png = await asyncio.to_thread(make_thumbnail, body, kind)
|
||||
if png is None:
|
||||
return JSONResponse({"error": "thumbnail unavailable"}, status_code=415)
|
||||
return _Response(
|
||||
png,
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
"Cache-Control": "private, max-age=300",
|
||||
},
|
||||
)
|
||||
|
||||
async def delete_(request: Request) -> Response:
|
||||
from turnstone.core.attachment_buffer import get_attachment_buffer
|
||||
|
||||
@@ -3900,6 +3942,7 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
|
||||
upload=upload,
|
||||
list=list_pending,
|
||||
get_content=get_content,
|
||||
thumbnail=get_thumbnail,
|
||||
delete=delete_,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Small PNG thumbnails of visual attachments, for the UI chip/preview.
|
||||
|
||||
``image`` → downscaled PNG; ``pdf`` → first page rendered (pypdfium2) then
|
||||
downscaled. Audio and text have no thumbnail. Never raises — returns ``None`` on
|
||||
any failure, and the UI falls back to a plain icon.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_THUMB_MAX_PX = 160
|
||||
|
||||
|
||||
def make_thumbnail(data: bytes, kind: str, *, max_px: int = _THUMB_MAX_PX) -> bytes | None:
|
||||
"""Return a small PNG thumbnail for an ``image``/``pdf`` blob, else ``None``."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError: # pragma: no cover - declared dependency; defensive
|
||||
log.warning("Pillow not installed; thumbnails unavailable")
|
||||
return None
|
||||
|
||||
try:
|
||||
if kind == "pdf":
|
||||
from turnstone.core.pdf import rasterize_pdf
|
||||
|
||||
pages = rasterize_pdf(data, max_pages=1)
|
||||
if not pages:
|
||||
return None
|
||||
img = Image.open(BytesIO(pages[0]))
|
||||
elif kind == "image":
|
||||
img = Image.open(BytesIO(data))
|
||||
else:
|
||||
return None
|
||||
rgb = img.convert("RGB")
|
||||
rgb.thumbnail((max_px, max_px))
|
||||
buf = BytesIO()
|
||||
rgb.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception as exc:
|
||||
log.warning("thumbnail generation failed (kind=%s): %s", kind, exc)
|
||||
return None
|
||||
@@ -66,6 +66,47 @@
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* --- attachment previews (image/pdf thumbnail · audio player · text snippet) --- */
|
||||
.composer-chip {
|
||||
flex-wrap: wrap;
|
||||
max-width: 340px;
|
||||
}
|
||||
.attach-preview-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-base);
|
||||
vertical-align: middle;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.attach-preview-audio {
|
||||
height: 30px;
|
||||
max-width: 240px;
|
||||
vertical-align: middle;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.attach-preview-snippet {
|
||||
display: inline-block;
|
||||
color: var(--fg-dim);
|
||||
font-size: 10px;
|
||||
max-width: 240px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.msg-user-attach-pill .attach-preview-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.msg-user-attach-pill .attach-preview-audio {
|
||||
height: 32px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.composer-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -47,6 +47,82 @@ function _inferKind(file) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
function _attachUrl(wsId, id, suffix) {
|
||||
return (
|
||||
"/v1/api/workstreams/" +
|
||||
encodeURIComponent(wsId) +
|
||||
"/attachments/" +
|
||||
encodeURIComponent(id) +
|
||||
suffix
|
||||
);
|
||||
}
|
||||
|
||||
function _kindIcon(kind) {
|
||||
if (kind === "image") return "🖼";
|
||||
if (kind === "audio") return "🎵";
|
||||
return "📄"; // pdf + text
|
||||
}
|
||||
|
||||
// Build an inline preview node for a committed attachment (real id), or null.
|
||||
// image/pdf → server-rendered thumbnail; audio → <audio> player; text → a lazy
|
||||
// snippet. Auth is cookie-based, so a plain media `src` works same-origin.
|
||||
export function buildAttachmentPreview(opts) {
|
||||
var kind = opts.kind,
|
||||
wsId = opts.wsId,
|
||||
id = opts.attachmentId;
|
||||
if (!wsId || !id) return null;
|
||||
if (kind === "image" || kind === "pdf") {
|
||||
var img = document.createElement("img");
|
||||
img.className = "attach-preview attach-preview-thumb";
|
||||
img.loading = "lazy";
|
||||
img.decoding = "async";
|
||||
img.alt = "";
|
||||
img.src = _attachUrl(wsId, id, "/thumbnail");
|
||||
// Drop the node if the thumbnail can't render, so the icon shows instead.
|
||||
img.addEventListener("error", function () {
|
||||
img.remove();
|
||||
});
|
||||
return img;
|
||||
}
|
||||
if (kind === "audio") {
|
||||
var audio = document.createElement("audio");
|
||||
audio.className = "attach-preview attach-preview-audio";
|
||||
audio.controls = true;
|
||||
audio.preload = "none";
|
||||
audio.src = _attachUrl(wsId, id, "/content");
|
||||
return audio;
|
||||
}
|
||||
if (kind === "text") {
|
||||
var snip = document.createElement("span");
|
||||
snip.className = "attach-preview attach-preview-snippet";
|
||||
var fetchFn =
|
||||
(opts && opts.authFetch) ||
|
||||
(typeof window !== "undefined" ? window.authFetch : null);
|
||||
if (typeof fetchFn !== "function") return null;
|
||||
fetchFn(_attachUrl(wsId, id, "/content"), {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
})
|
||||
.then(function (r) {
|
||||
return r && r.ok ? r.text() : "";
|
||||
})
|
||||
.then(function (t) {
|
||||
if (!t) {
|
||||
snip.remove();
|
||||
return;
|
||||
}
|
||||
snip.textContent =
|
||||
t.slice(0, 240).replace(/\s+/g, " ").trim() +
|
||||
(t.length > 240 ? "…" : "");
|
||||
})
|
||||
.catch(function () {
|
||||
snip.remove();
|
||||
});
|
||||
return snip;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _toastError(msg) {
|
||||
if (typeof window.toast !== "undefined" && window.toast.error) {
|
||||
window.toast.error(msg);
|
||||
@@ -89,7 +165,7 @@ export function createAttachmentController(opts) {
|
||||
var icon = document.createElement("span");
|
||||
icon.className = "composer-chip-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = info.kind === "image" ? "🖼" : "📄";
|
||||
icon.textContent = _kindIcon(info.kind);
|
||||
chip.appendChild(icon);
|
||||
|
||||
var name = document.createElement("span");
|
||||
@@ -117,10 +193,37 @@ export function createAttachmentController(opts) {
|
||||
});
|
||||
chip.appendChild(btn);
|
||||
|
||||
_applyPreview(chip, info);
|
||||
chipsEl.appendChild(chip);
|
||||
return chip;
|
||||
}
|
||||
|
||||
// Insert a committed-attachment preview into a chip (image/pdf thumbnail,
|
||||
// audio player, or a lazy text snippet). No-op for the in-flight placeholder
|
||||
// (no real id yet); the swap re-applies once the real id lands.
|
||||
function _applyPreview(chip, info) {
|
||||
var idStr = "" + (info.attachment_id || "");
|
||||
if (info.uploading || !idStr || idStr.indexOf("__uploading_") === 0) return;
|
||||
var prev = buildAttachmentPreview({
|
||||
kind: info.kind,
|
||||
wsId: getWsId(),
|
||||
attachmentId: info.attachment_id,
|
||||
authFetch: _authFetch,
|
||||
});
|
||||
var old = chip.querySelector(".attach-preview");
|
||||
if (old) old.remove();
|
||||
if (!prev) return;
|
||||
if (info.kind === "image" || info.kind === "pdf") {
|
||||
var icon = chip.querySelector(".composer-chip-icon");
|
||||
if (icon) icon.replaceWith(prev);
|
||||
else chip.insertBefore(prev, chip.firstChild);
|
||||
} else {
|
||||
var btn = chip.querySelector(".composer-chip-remove");
|
||||
if (btn) chip.insertBefore(prev, btn);
|
||||
else chip.appendChild(prev);
|
||||
}
|
||||
}
|
||||
|
||||
function _findChip(id) {
|
||||
return chipsEl.querySelector('[data-attachment-id="' + id + '"]');
|
||||
}
|
||||
@@ -159,6 +262,11 @@ export function createAttachmentController(opts) {
|
||||
var chip = _findChip(placeholderId);
|
||||
if (chip) {
|
||||
chip.dataset.attachmentId = info.attachment_id;
|
||||
// classify_upload on the server is authoritative — adopt its kind for the
|
||||
// chip styling + icon, then render the preview now there's a real id.
|
||||
chip.className = "composer-chip composer-chip-" + (info.kind || "other");
|
||||
var swapIcon = chip.querySelector(".composer-chip-icon");
|
||||
if (swapIcon) swapIcon.textContent = _kindIcon(info.kind);
|
||||
var name = chip.querySelector(".composer-chip-name");
|
||||
if (name) {
|
||||
name.textContent = info.filename || "(unnamed)";
|
||||
@@ -166,6 +274,7 @@ export function createAttachmentController(opts) {
|
||||
}
|
||||
var size = chip.querySelector(".composer-chip-size");
|
||||
if (size) size.textContent = formatSize(info.size_bytes || 0);
|
||||
_applyPreview(chip, info);
|
||||
} else {
|
||||
renderChip(info);
|
||||
}
|
||||
@@ -315,3 +424,4 @@ export function createAttachmentController(opts) {
|
||||
// Still-classic consumers reach this as a global at event/boot time (after
|
||||
// this deferred module evaluated). New module code imports instead.
|
||||
window.createAttachmentController = createAttachmentController;
|
||||
window.buildAttachmentPreview = buildAttachmentPreview;
|
||||
|
||||
@@ -366,6 +366,7 @@ class Pane {
|
||||
if (Array.isArray(attachments) && attachments.length > 0) {
|
||||
const pills = document.createElement("div");
|
||||
pills.className = "msg-user-attach";
|
||||
const attachWsId = this.wsId;
|
||||
attachments.forEach(function (a) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className =
|
||||
@@ -373,13 +374,35 @@ class Pane {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "msg-user-attach-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = a.kind === "image" ? "\ud83d\uddbc" : "\ud83d\udcc4";
|
||||
icon.textContent =
|
||||
a.kind === "image"
|
||||
? "\ud83d\uddbc"
|
||||
: a.kind === "audio"
|
||||
? "\ud83c\udfb5"
|
||||
: "\ud83d\udcc4";
|
||||
pill.appendChild(icon);
|
||||
const nameEl = document.createElement("span");
|
||||
nameEl.className = "msg-user-attach-name";
|
||||
nameEl.textContent =
|
||||
a.filename || (a.kind === "image" ? "image" : "document");
|
||||
a.filename ||
|
||||
(a.kind === "image"
|
||||
? "image"
|
||||
: a.kind === "audio"
|
||||
? "audio"
|
||||
: "document");
|
||||
pill.appendChild(nameEl);
|
||||
const prev =
|
||||
typeof window.buildAttachmentPreview === "function"
|
||||
? window.buildAttachmentPreview({
|
||||
kind: a.kind,
|
||||
wsId: attachWsId,
|
||||
attachmentId: a.attachment_id,
|
||||
})
|
||||
: null;
|
||||
if (prev) {
|
||||
if (a.kind === "image" || a.kind === "pdf") icon.replaceWith(prev);
|
||||
else pill.appendChild(prev);
|
||||
}
|
||||
pills.appendChild(pill);
|
||||
});
|
||||
el.appendChild(pills);
|
||||
|
||||
Reference in New Issue
Block a user