Event invitations in Mail, video previews, People tab, matching avatars
Invitations received by email (iMIP): above the message body, a card with when, what, where (or "Join video call"), the organizer, what else is in the calendar then (a conflict, else the previous event that day), and Yes / No / Maybe. Yes and Maybe put the event in the calendar (or update it), No takes it out — and is still shown as the answer given; each sends the organizer the iTIP REPLY, then a "Reply sent" toast, and an open calendar refreshes. A cancellation offers "Remove from calendar". It is the calendar plugin's message.banner slot (ui/invite.js, imip.py, /invitations endpoints); the SDK gains the user's own accounts and settings. An Outlook-style invitation sent only as text/calendar is now kept as invite.ics too. Calendar: clicking an empty day in the side panel's agenda opens a new event on that date; a video-call link can be removed (✕ beside Copy); the page guests see after answering, and our invitations' footer, say Ultimo Calendar (with the logo). Attachments: videos (mp4, mov, webm, …) play in the full-screen preview, paused until played; attachments are now served from disk with HTTP Range, so a video starts at once and can be seeked. People tab: everyone in the thread, the user included, A–Z, under the name any message gives them. Initials circles share one colour rule in the list, the reading pane and the People tab. The contact view drops its duplicate "Message" button. A general corner toast (webmail:toast) joins Undo's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -605,3 +605,40 @@ async def test_attachment_thumbnails(api: ApiCtx) -> None:
|
||||
|
||||
assert (await api.client.get(url("notes.txt"))).status_code == 404
|
||||
assert (await api.client.get(url("broken.jpg"))).status_code == 404
|
||||
|
||||
|
||||
async def test_video_attachment_streams_with_range(api: ApiCtx) -> None:
|
||||
# A video plays in the preview: served inline as video (whatever the sender
|
||||
# called it) and in pieces on request (HTTP Range), so it can be seeked.
|
||||
from app.indexer import blobstore
|
||||
from app.models import Attachment
|
||||
|
||||
assert api.session is not None
|
||||
mid = api.message_ids[-1]
|
||||
data = bytes(range(256)) * 40 # 10 240 bytes standing in for a video
|
||||
att = Attachment(
|
||||
message_id=mid,
|
||||
user_id=api.user_id,
|
||||
filename="clip.MOV",
|
||||
mime_type="application/octet-stream",
|
||||
size_bytes=len(data),
|
||||
content_id=None,
|
||||
blob_sha256=blobstore.store(api.user_id, data),
|
||||
)
|
||||
api.session.add(att)
|
||||
await api.session.commit()
|
||||
url = f"/api/messages/{mid}/attachments/{att.id}"
|
||||
|
||||
whole = await api.client.get(url)
|
||||
assert whole.status_code == 200 and whole.content == data
|
||||
assert whole.headers["content-type"] == "video/mp4"
|
||||
assert whole.headers["content-disposition"].startswith("inline")
|
||||
assert whole.headers["accept-ranges"] == "bytes"
|
||||
|
||||
part = await api.client.get(url, headers={"Range": "bytes=1000-1999"})
|
||||
assert part.status_code == 206 and part.content == data[1000:2000]
|
||||
assert part.headers["content-range"] == f"bytes 1000-1999/{len(data)}"
|
||||
|
||||
saved = await api.client.get(url + "?download=1")
|
||||
assert saved.headers["content-type"] == "application/octet-stream"
|
||||
assert saved.headers["content-disposition"].startswith("attachment")
|
||||
|
||||
Reference in New Issue
Block a user