Implement attachment view

This commit is contained in:
2026-02-15 18:58:58 +00:00
parent e7aac36ef9
commit 4cf75b9923
8 changed files with 914 additions and 69 deletions

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
import hashlib
import json
import re
import time
from datetime import datetime, timezone as dt_timezone
from urllib.parse import urlencode
from urllib.parse import urlencode, urlparse
from asgiref.sync import async_to_sync
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -27,11 +28,35 @@ from core.models import (
Person,
PersonIdentifier,
)
from core.realtime.typing_state import get_person_typing_state
from core.views.workspace import _build_engage_payload, _parse_draft_options
COMPOSE_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
COMPOSE_AI_CACHE_TTL = 60 * 30
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
IMAGE_EXTENSIONS = (
".png",
".jpg",
".jpeg",
".gif",
".webp",
".bmp",
".avif",
".svg",
)
def _uniq_ordered(values):
seen = set()
output = []
for value in values:
cleaned = _clean_url(value)
if not cleaned or cleaned in seen:
continue
seen.add(cleaned)
output.append(cleaned)
return output
def _default_service(service: str | None) -> str:
@@ -69,13 +94,75 @@ def _is_outgoing(msg: Message) -> bool:
return str(msg.custom_author or "").upper() in {"USER", "BOT"}
def _clean_url(candidate: str) -> str:
return str(candidate or "").strip().rstrip(".,);:!?\"'")
def _extract_urls(text_value: str) -> list[str]:
found = []
for match in URL_PATTERN.findall(str(text_value or "")):
cleaned = _clean_url(match)
if cleaned and cleaned not in found:
found.append(cleaned)
return found
def _is_url_only_text(text_value: str) -> bool:
lines = [line.strip() for line in str(text_value or "").splitlines() if line.strip()]
if not lines:
return False
return all(bool(URL_PATTERN.fullmatch(line)) for line in lines)
def _looks_like_image_url(url_value: str) -> bool:
if not url_value:
return False
parsed = urlparse(url_value)
path = str(parsed.path or "").lower()
return path.endswith(IMAGE_EXTENSIONS)
def _image_url_from_text(text_value: str) -> str:
urls = _image_urls_from_text(text_value)
return urls[0] if urls else ""
def _image_urls_from_text(text_value: str) -> list[str]:
urls = _uniq_ordered(_extract_urls(text_value))
if not urls:
return []
confident = [url for url in urls if _looks_like_image_url(url)]
if confident:
return confident
# Fallback: some XMPP upload URLs have no file extension.
if _is_url_only_text(text_value):
return urls
return []
def _serialize_message(msg: Message) -> dict:
text_value = str(msg.text or "")
image_urls = _image_urls_from_text(text_value)
image_url = image_urls[0] if image_urls else ""
hide_text = bool(
image_urls
and _is_url_only_text(text_value)
and all(_looks_like_image_url(url) for url in image_urls)
)
display_text = text_value if text_value.strip() else ("(no text)" if not image_url else "")
author = str(msg.custom_author or "").strip()
return {
"id": str(msg.id),
"ts": int(msg.ts or 0),
"display_ts": _format_ts_label(int(msg.ts or 0)),
"text": str(msg.text or ""),
"text": text_value,
"display_text": display_text,
"image_url": image_url,
"image_urls": image_urls,
"hide_text": hide_text,
"author": author,
"outgoing": _is_outgoing(msg),
}
@@ -400,6 +487,10 @@ def _panel_context(
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
typing_state = get_person_typing_state(
user_id=request.user.id,
person_id=base["person"].id if base["person"] else None,
)
return {
"service": base["service"],
@@ -430,6 +521,7 @@ def _panel_context(
),
"manual_icon_class": "fa-solid fa-paper-plane",
"panel_id": f"compose-panel-{unique}",
"typing_state_json": json.dumps(typing_state),
}
@@ -560,6 +652,10 @@ class ComposeThread(LoginRequiredMixin, View):
payload = {
"messages": [_serialize_message(msg) for msg in messages],
"last_ts": latest_ts,
"typing": get_person_typing_state(
user_id=request.user.id,
person_id=base["person"].id if base["person"] else None,
),
}
return JsonResponse(payload)
@@ -891,6 +987,28 @@ class ComposeEngageSend(LoginRequiredMixin, View):
class ComposeSend(LoginRequiredMixin, View):
@staticmethod
def _response(request, *, ok, message="", level="info"):
response = render(
request,
"partials/compose-send-status.html",
{
"notice_message": message,
"notice_level": level,
},
)
trigger_payload = {
"composeSendResult": {
"ok": bool(ok),
"message": str(message or ""),
"level": str(level or "info"),
}
}
if ok:
trigger_payload["composeMessageSent"] = True
response["HX-Trigger"] = json.dumps(trigger_payload)
return response
def post(self, request):
service = _default_service(request.POST.get("service"))
identifier = str(request.POST.get("identifier") or "").strip()
@@ -908,21 +1026,20 @@ class ComposeSend(LoginRequiredMixin, View):
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
if failsafe_arm != "1" or failsafe_confirm != "1":
return render(
return self._response(
request,
"partials/compose-send-status.html",
{
"notice_message": "Enable send confirmation before sending.",
"notice_level": "warning",
},
ok=False,
message="Enable send confirmation before sending.",
level="warning",
)
text = str(request.POST.get("text") or "").strip()
if not text:
return render(
return self._response(
request,
"partials/compose-send-status.html",
{"notice_message": "Message is empty.", "notice_level": "danger"},
ok=False,
message="Message is empty.",
level="danger",
)
base = _context_base(request.user, service, identifier, person)
@@ -933,13 +1050,11 @@ class ComposeSend(LoginRequiredMixin, View):
attachments=[],
)
if not ts:
return render(
return self._response(
request,
"partials/compose-send-status.html",
{
"notice_message": "Send failed. Check service account state.",
"notice_level": "danger",
},
ok=False,
message="Send failed. Check service account state.",
level="danger",
)
if base["person_identifier"] is not None:
@@ -957,10 +1072,4 @@ class ComposeSend(LoginRequiredMixin, View):
custom_author="USER",
)
response = render(
request,
"partials/compose-send-status.html",
{"notice_message": "Sent.", "notice_level": "success"},
)
response["HX-Trigger"] = "composeMessageSent"
return response
return self._response(request, ok=True, message="", level="success")