Compare commits
3 Commits
24a79177e8
...
8856c38b4d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8856c38b4d | |||
| 08d370b020 | |||
| 076f55f380 |
@ -202,6 +202,7 @@ The farm refreshes live over the pub/sub bus (a watered build appears on the own
|
||||
- **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette.
|
||||
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
|
||||
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
|
||||
- **Paste an image to attach it** - pressing Ctrl+V (Cmd+V) with a screenshot or copied image on the clipboard while writing a post, a comment, a direct message, an issue, a gist, or a project attaches it immediately, with no trip through the file picker. The upload, its limits, and the resulting attachment are identical to picking the file by hand.
|
||||
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
|
||||
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
|
||||
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.
|
||||
|
||||
@ -29,6 +29,23 @@
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.attachment-gallery.single .attachment-gallery-item:has(.gallery-thumb) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.attachment-gallery.single .attachment-gallery-item:has(.gallery-thumb):hover {
|
||||
transform: none;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.attachment-gallery.single .gallery-thumb {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.attachment-gallery-item:has(.non-image) {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
|
||||
@ -92,7 +92,11 @@ File validation: max 5MB, allowed extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `
|
||||
|
||||
**Ingesting a file from a URL.** `store_attachment_from_url(url, user_uid, filename=None)` (async, in `attachments.py`) is the remote counterpart to `store_attachment`: it downloads the URL on the server through `fetch_remote_file()` - SSRF-guarded (`_guard_public_url` resolves the host and refuses private/loopback/reserved/multicast addresses, mirroring the Devii fetch guard) and size-capped (streams, aborting once `_get_max_upload_bytes()` is exceeded) - resolves a filename from the URL path or the response `Content-Type` (`MIME_TO_EXT`), then calls `store_attachment()` so the bytes land in the **exact same** pipeline (validation, thumbnailing, DB row). It raises `RemoteFetchError(message, status)` which the route maps to an HTTP status. It is exposed at `POST /uploads/upload-url` (`UploadUrlForm{url, filename?}`, `require_user_api`) and as the Devii catalog action `attach_url` (handler `http`, `requires_auth=True`); both return the same record as `/uploads/upload`. The returned `uid` binds to a resource the same way as any upload - via `attachment_uids` at create/edit time - so attaching a remote image is just `attach_url` then `create_post`/`create_project`/etc. with that uid. Do not re-download remote files in a router; reuse this helper so the guard and size cap stay in one place.
|
||||
|
||||
`_row_to_attachment()` / `store_attachment()` expose `is_image` and `is_video` (derived from the mime prefix). The shared partial `templates/_attachment_display.html` branches image -> `<img>`, video -> `<video controls preload="metadata" class="gallery-video">`, else download link; rendering through this one partial is what makes video work across every feature at once. `AttachmentOut` (`schemas.py`) carries both flags - add new display keys there too or JSON drops them.
|
||||
`_row_to_attachment()` / `store_attachment()` expose `is_image` and `is_video` (derived from the mime prefix). The shared partial `templates/_attachment_display.html` branches image -> `<img>`, video -> `<video controls preload="metadata" class="gallery-video">`, else download link; rendering through this one partial is what makes video work across every feature at once.
|
||||
|
||||
**Every caller MUST bind `attachments` before including the partial** - `{% set attachments = item.get('attachments', []) %}` or `{% with attachments=... %}`. The partial iterates the bare name `attachments`, so a caller that only guards on `{% if item.attachments %}` and includes without binding renders the gallery from whatever `attachments` happens to be in the surrounding page context. This is not theoretical: `_post_card.html` did exactly that, so **feed and profile cards silently rendered an empty gallery for every post that had an image**, and on a project page (where `project_detail.html` sets `attachments` at template scope for the project's own files) a devlog card would have rendered the *project's* attachments as if they were the post's. Guarded by `tests/e2e/feed.py::test_feed_card_shows_the_post_image`.
|
||||
|
||||
**A lone attachment is a hero, not a chip.** When the gallery holds exactly one item the partial adds a `single` class, and `attachments.css` widens that item to the full content column (`max-height: 480px`, `object-fit: contain`, no hover scale) instead of the 240x200 chip a multi-item gallery uses. **The `single` branch must serve `att['url']`, never `thumbnail_url`** - a thumbnail is 200px on its longest side, so blowing it up to the column width renders visibly blurry. That is the whole reason the src is a conditional rather than "thumbnail when one exists". Because the partial is shared, this applies everywhere at once: post cards, post detail, comments, gists, projects and chat bubbles. Animated GIFs never had a thumbnail to begin with (`THUMBNAIL_EXTENSIONS` excludes `.gif`, so animation survives), which means they already took the original-file path and simply render larger now. `AttachmentOut` (`schemas.py`) carries both flags - add new display keys there too or JSON drops them.
|
||||
|
||||
Media is served **inline** (not forced-download) for known-safe types only. The set `INLINE_MEDIA_EXTENSIONS` in `main.py` (`UploadStaticFiles`) and the matching `map $uri $upload_disposition` in `nginx/nginx.conf.template` must stay in sync: images/video/audio -> `inline` (so `<video>` plays and seeks via Range), everything else -> `attachment`. SVG is deliberately excluded from both (stored-XSS defense). `ContentRenderer.js` embeds direct video URLs typed into content via `videoExtRe`, mirroring its image handling.
|
||||
|
||||
@ -104,6 +108,8 @@ Every file-upload UI is the one custom element `dp-upload` (`static/js/component
|
||||
- `direct` (the project file browser, `project_files.html`): uploads to a custom `endpoint` with `field-name` plus a settable `extraFields` (e.g. `{path}`) and emits `dp-upload:uploaded` / `dp-upload:done` / `dp-upload:error`. `ProjectFiles.uploadTo(dir)` sets `extraFields` then calls `widget.open()`, and refreshes the tree on `done`.
|
||||
- `field` (create-post image, `feed.html`): wraps a real `<input type="file" name="image">` that submits with the form - no AJAX, inline-image flow unchanged.
|
||||
|
||||
**Paste-to-attach is the opt-in `paste` boolean attribute**, not a per-form handler. With it set, `dp-upload` binds ONE `paste` listener on its `closest("form")` and routes the clipboard's image files through the same `handleFiles` path as the picker and the drop target, so validation, limits, the terms gate and the hidden `attachment_uids` field are shared - never re-implement a clipboard reader in a page controller. It is opt-in because a form may hold several upload buttons (`projects.html` has cover + logo beside the attachment one) and a form-wide default would attach one pasted image to all of them; mark exactly the button that owns the form's content attachments. Set on `_attachment_form.html` (so every form including it - post composer, post edit, gists, projects, issues, screenshots - inherits it), `_comment_form.html`, `messages.html`, and the `mode="embed"` skeleton `AppChat` builds. Non-image clipboard payloads fall through untouched, so pasting text still types.
|
||||
|
||||
The component validates size/type/count and reports errors via `app.toast`. CSS is `.dp-upload-*` in `components.css`; the old `.attachment-upload-*` upload-widget styles were removed, but the `.attachment-gallery`/`.attachment-lightbox` display styles (for already-saved attachments) remain.
|
||||
|
||||
## ReportDialog (`ReportDialog.js`, `app.reportDialog`)
|
||||
|
||||
@ -199,6 +199,7 @@ export class AppChat extends Component {
|
||||
|
||||
this.upload = document.createElement("dp-upload");
|
||||
this.upload.setAttribute("multiple", "");
|
||||
this.upload.setAttribute("paste", "");
|
||||
this.upload.setAttribute("max-files", String(this.maxAttachments));
|
||||
|
||||
this.sendBtn = document.createElement("button");
|
||||
|
||||
@ -91,6 +91,9 @@ export class AppUpload extends Component {
|
||||
this.pendingSubmitForm = null;
|
||||
const form = this.closest("form");
|
||||
if (form) {
|
||||
if (this.boolAttr("paste")) {
|
||||
form.addEventListener("paste", (event) => this.handlePaste(event));
|
||||
}
|
||||
form.addEventListener("submit", (event) => {
|
||||
if (this.busyCount > 0) {
|
||||
event.preventDefault();
|
||||
@ -108,6 +111,19 @@ export class AppUpload extends Component {
|
||||
this.input.click();
|
||||
}
|
||||
|
||||
handlePaste(event) {
|
||||
const data = event.clipboardData;
|
||||
const files = Array.from(data ? data.files : [])
|
||||
.filter((file) => file.type.startsWith("image/"));
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
if (!data.getData("text/plain")) {
|
||||
event.preventDefault();
|
||||
}
|
||||
this.handleFiles(files);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.items = [];
|
||||
if (this.mode === "field") {
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
<div class="attachment-gallery">
|
||||
{% set _single = attachments|length == 1 %}
|
||||
<div class="attachment-gallery{% if _single %} single{% endif %}">
|
||||
{% for att in attachments %}
|
||||
<div class="attachment-gallery-item">
|
||||
{% if att.get('is_image') and att.get('thumbnail_url') %}
|
||||
<img src="{{ att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% elif att.get('is_image') %}
|
||||
<img src="{{ att['url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% if att.get('is_image') %}
|
||||
<img src="{{ att['url'] if _single or not att.get('thumbnail_url') else att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% elif att.get('is_video') %}
|
||||
<video src="{{ att['url'] }}" controls preload="metadata" class="gallery-video"></video>
|
||||
{% elif att.get('is_audio') %}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<dp-upload multiple
|
||||
<dp-upload multiple paste
|
||||
max-size="{{ max_upload_size_mb() }}"
|
||||
max-files="{{ max_attachments_per_resource() }}"
|
||||
allowed-types="{{ allowed_file_types() }}"></dp-upload>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
{% set _user = user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
<textarea name="content" placeholder="Your opinion goes here..." required aria-required="true" aria-label="Comment" maxlength="125000" class="emoji-picker-target" data-mention></textarea>
|
||||
<div class="comment-form-actions">
|
||||
<dp-upload multiple
|
||||
<dp-upload multiple paste
|
||||
max-size="{{ max_upload_size_mb() }}"
|
||||
max-files="{{ max_attachments_per_resource() }}"
|
||||
allowed-types="{{ allowed_file_types() }}"></dp-upload>
|
||||
|
||||
@ -25,7 +25,8 @@
|
||||
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if item.attachments %}
|
||||
{% set attachments = item.get('attachments', []) %}
|
||||
{% if attachments %}
|
||||
{% include "_attachment_display.html" %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ Set with the `mode` attribute:
|
||||
| `label` | (none) | Optional button text shown beside the icon. |
|
||||
| `multiple` | off | Allow selecting more than one file. |
|
||||
| `directory` | off | Allow selecting a whole directory (`webkitdirectory`). |
|
||||
| `paste` | off | Attach images pasted anywhere in the surrounding `<form>` (clipboard screenshots). |
|
||||
| `accept` | (none) | Native accept filter, e.g. `image/*`. |
|
||||
| `allowed-types` | (none) | Comma list of permitted extensions, e.g. `.jpg,.png`. |
|
||||
| `max-size` | `10` | Maximum size per file, in MB. |
|
||||
|
||||
@ -70,7 +70,7 @@
|
||||
<form class="messages-input-area" method="POST" action="/messages/send" data-live-form>
|
||||
<input type="hidden" name="receiver_uid" value="{{ other_user['uid'] }}">
|
||||
<textarea name="content" placeholder="Type a message..." maxlength="2000" autocomplete="off" data-mention aria-label="Type a message" rows="1"></textarea>
|
||||
<dp-upload multiple
|
||||
<dp-upload multiple paste
|
||||
max-size="{{ max_upload_size_mb() }}"
|
||||
max-files="5"
|
||||
allowed-types="{{ allowed_file_types() }}"></dp-upload>
|
||||
|
||||
@ -319,6 +319,48 @@ def login_user(page, user):
|
||||
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def paste_image(page, selector, name="pasted.png"):
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
|
||||
page.eval_on_selector(
|
||||
selector,
|
||||
"""(target, [data, filename]) => {
|
||||
const bytes = Uint8Array.from(atob(data), (ch) => ch.charCodeAt(0));
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([bytes], filename, { type: "image/png" }));
|
||||
target.dispatchEvent(
|
||||
new ClipboardEvent("paste", {
|
||||
clipboardData: transfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
}""",
|
||||
[base64.b64encode(buf.getvalue()).decode(), name],
|
||||
)
|
||||
|
||||
|
||||
def create_post_with_files(page, content, files, expected_count=1):
|
||||
from playwright.sync_api import expect
|
||||
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
||||
page.locator(".feed-fab").first.click()
|
||||
page.fill("#post-content", content)
|
||||
page.locator("#create-post-modal dp-upload .dp-upload-input").first.set_input_files(
|
||||
files
|
||||
)
|
||||
expect(
|
||||
page.locator("#create-post-modal dp-upload .dp-upload-count").first
|
||||
).to_have_text(f"({expected_count})", timeout=15000)
|
||||
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def assert_share_copies(page, expected_fragment):
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
from tests.conftest import BASE_URL, create_post_with_files, paste_image
|
||||
import time
|
||||
import requests
|
||||
from playwright.sync_api import expect
|
||||
@ -702,6 +702,46 @@ def test_feed_politics_topic(alice):
|
||||
assert politics_link.is_visible()
|
||||
|
||||
|
||||
def test_paste_image_attaches_in_post_composer(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
||||
page.locator(".feed-fab").first.click()
|
||||
page.locator("#post-content").wait_for(state="visible", timeout=10000)
|
||||
page.locator("#create-post-modal dp-upload .dp-upload-btn").first.wait_for(
|
||||
state="visible", timeout=10000
|
||||
)
|
||||
paste_image(page, "#post-content")
|
||||
page.locator("#create-post-modal dp-upload .dp-upload-count").first.wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
expect(
|
||||
page.locator("#create-post-modal dp-upload input[name='attachment_uids']")
|
||||
).to_have_value(re.compile(r".+"))
|
||||
|
||||
|
||||
def test_feed_card_shows_the_post_image(alice):
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
page, _ = alice
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (600, 400), (28, 120, 200)).save(buf, "PNG")
|
||||
create_post_with_files(
|
||||
page,
|
||||
"Feed card image rendering check",
|
||||
[{"name": "card.png", "mimeType": "image/png", "buffer": buf.getvalue()}],
|
||||
)
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
card = page.locator(
|
||||
".post-card:has-text('Feed card image rendering check')"
|
||||
).first
|
||||
card.wait_for(state="visible", timeout=10000)
|
||||
image = card.locator(".attachment-gallery.single .gallery-thumb").first
|
||||
image.wait_for(state="visible", timeout=10000)
|
||||
assert "_thumb" not in image.get_attribute("src")
|
||||
|
||||
|
||||
def test_create_post_cancel_modal(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import re
|
||||
import time
|
||||
from playwright.sync_api import expect
|
||||
from tests.conftest import BASE_URL
|
||||
from tests.conftest import BASE_URL, paste_image
|
||||
from devplacepy.database import get_table
|
||||
import requests
|
||||
def _seed_news_seo():
|
||||
@ -183,6 +183,24 @@ def test_send_message_appears_in_thread(alice):
|
||||
page.locator(f".message-bubble:has-text('{msg}')").first.wait_for(state="visible")
|
||||
|
||||
|
||||
def test_paste_image_attaches_in_chat(alice):
|
||||
page, _ = alice
|
||||
bob = get_table("users").find_one(username="bob_test")
|
||||
page.goto(
|
||||
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
|
||||
)
|
||||
page.locator(".messages-input-area dp-upload .dp-upload-btn").first.wait_for(
|
||||
state="visible", timeout=10000
|
||||
)
|
||||
paste_image(page, ".messages-input-area textarea[name='content']")
|
||||
page.locator(".messages-input-area dp-upload .dp-upload-count").first.wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
expect(
|
||||
page.locator(".messages-input-area dp-upload input[name='attachment_uids']")
|
||||
).to_have_value(re.compile(r".+"))
|
||||
|
||||
|
||||
def test_messages_page_loads(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
||||
|
||||
@ -2,7 +2,12 @@
|
||||
|
||||
import re
|
||||
from playwright.sync_api import expect
|
||||
from tests.conftest import BASE_URL, assert_share_copies
|
||||
from tests.conftest import (
|
||||
BASE_URL,
|
||||
assert_share_copies,
|
||||
create_post_with_files,
|
||||
paste_image,
|
||||
)
|
||||
def create_post(page, topic="random", content="Test post content", title=None):
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
||||
@ -13,6 +18,27 @@ def create_post(page, topic="random", content="Test post content", title=None):
|
||||
page.fill("#post-title", title)
|
||||
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||
def _png_bytes(color=(0, 128, 255), size=(600, 400)):
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _gif_bytes():
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
frames = [Image.new("RGB", (40, 40), c) for c in ((255, 0, 0), (0, 0, 255))]
|
||||
buf = io.BytesIO()
|
||||
frames[0].save(
|
||||
buf, "GIF", save_all=True, append_images=frames[1:], duration=120, loop=0
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _profile_stars(page, username):
|
||||
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
|
||||
value = page.locator(
|
||||
@ -375,6 +401,72 @@ def test_emoji_picker_opens(alice):
|
||||
assert page.locator("emoji-picker").first.is_visible()
|
||||
|
||||
|
||||
def test_paste_image_attaches_in_comment_form(alice):
|
||||
page, _ = alice
|
||||
create_post(page, "random", "Post for pasted comment attachment")
|
||||
page.locator(".comment-form dp-upload .dp-upload-btn").first.wait_for(
|
||||
state="visible", timeout=10000
|
||||
)
|
||||
paste_image(page, ".comment-form textarea[name='content']")
|
||||
page.locator(".comment-form dp-upload .dp-upload-count").first.wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
expect(
|
||||
page.locator(".comment-form dp-upload input[name='attachment_uids']")
|
||||
).to_have_value(re.compile(r".+"))
|
||||
|
||||
|
||||
def test_single_image_post_shows_the_original_full_size(alice):
|
||||
page, _ = alice
|
||||
create_post_with_files(
|
||||
page,
|
||||
"Post carrying exactly one image",
|
||||
[{"name": "shot.png", "mimeType": "image/png", "buffer": _png_bytes()}],
|
||||
)
|
||||
gallery = page.locator(".attachment-gallery.single")
|
||||
gallery.wait_for(state="visible", timeout=10000)
|
||||
img = gallery.locator(".gallery-thumb").first
|
||||
src = img.get_attribute("src")
|
||||
assert "_thumb" not in src, f"hero image served the 200px thumbnail: {src}"
|
||||
assert src == img.get_attribute("data-full")
|
||||
|
||||
|
||||
def test_multiple_image_post_keeps_thumbnails(alice):
|
||||
page, _ = alice
|
||||
create_post_with_files(
|
||||
page,
|
||||
"Post carrying two images",
|
||||
[
|
||||
{"name": "one.png", "mimeType": "image/png", "buffer": _png_bytes()},
|
||||
{
|
||||
"name": "two.png",
|
||||
"mimeType": "image/png",
|
||||
"buffer": _png_bytes((200, 30, 90)),
|
||||
},
|
||||
],
|
||||
expected_count=2,
|
||||
)
|
||||
page.locator(".attachment-gallery").first.wait_for(state="visible", timeout=10000)
|
||||
assert page.locator(".attachment-gallery.single").count() == 0
|
||||
thumbs = page.locator(".attachment-gallery .gallery-thumb")
|
||||
assert thumbs.count() == 2
|
||||
for i in range(thumbs.count()):
|
||||
assert "_thumb" in thumbs.nth(i).get_attribute("src")
|
||||
|
||||
|
||||
def test_animated_gif_post_serves_the_original_file(alice):
|
||||
page, _ = alice
|
||||
create_post_with_files(
|
||||
page,
|
||||
"Post carrying an animated gif",
|
||||
[{"name": "loop.gif", "mimeType": "image/gif", "buffer": _gif_bytes()}],
|
||||
)
|
||||
img = page.locator(".attachment-gallery .gallery-thumb").first
|
||||
img.wait_for(state="visible", timeout=10000)
|
||||
src = img.get_attribute("src")
|
||||
assert src.endswith(".gif"), f"animation lost, served {src}"
|
||||
|
||||
|
||||
def test_attachment_upload_ui(alice):
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
Loading…
Reference in New Issue
Block a user