Reimplement compose and add tiling windows

This commit is contained in:
2026-03-12 22:03:30 +00:00
parent 79766d279d
commit 6ceff63b71
126 changed files with 5111 additions and 10796 deletions

View File

@@ -14,6 +14,7 @@ from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core import signing
from django.core.cache import cache
from django.db.models import Q
from django.http import (
HttpResponse,
HttpResponseBadRequest,
@@ -21,6 +22,8 @@ from django.http import (
JsonResponse,
)
from django.shortcuts import get_object_or_404, render
from django.template.loader import render_to_string
from django.templatetags.static import static
from django.urls import reverse
from django.utils import timezone as dj_timezone
from django.views import View
@@ -64,6 +67,10 @@ from core.views.workspace import (
COMPOSE_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
COMPOSE_AI_CACHE_TTL = 60 * 30
COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE = 12
COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE = 15
COMPOSE_THREAD_WINDOW_OPTIONS = [20, 40, 60, 100, 200]
COMPOSE_COMMAND_SLUGS = ("bp",)
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
SIGNAL_UUID_PATTERN = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
@@ -189,6 +196,14 @@ def _safe_after_ts(raw) -> int:
return max(0, value)
def _safe_page(raw) -> int:
try:
value = int(raw or 1)
except (TypeError, ValueError):
value = 1
return max(1, min(value, 100))
def _request_scope(request, source: str = "GET"):
data = request.GET if str(source).upper() == "GET" else request.POST
service = _default_service(data.get("service"))
@@ -200,6 +215,33 @@ def _request_scope(request, source: str = "GET"):
return service, identifier, person
def _normalize_contact_search_query(raw) -> str:
return " ".join(str(raw or "").strip().split())
def _filter_manual_contact_rows(rows, query: str):
search = _normalize_contact_search_query(query).lower()
if not search:
return list(rows or [])
tokens = [token for token in search.split(" ") if token]
if not tokens:
return list(rows or [])
filtered = []
for row in list(rows or []):
haystack = " ".join(
[
str(row.get("person_name") or ""),
str(row.get("linked_person_name") or ""),
str(row.get("detected_name") or ""),
str(row.get("service") or ""),
str(row.get("identifier") or ""),
]
).lower()
if all(token in haystack for token in tokens):
filtered.append(row)
return filtered
def _format_ts_label(ts_value: int) -> str:
try:
as_dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
@@ -208,6 +250,37 @@ def _format_ts_label(ts_value: int) -> str:
return str(ts_value or "")
def _format_ts_datetime_label(ts_value: int) -> str:
try:
as_dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
return dj_timezone.localtime(as_dt).strftime("%Y-%m-%d %H:%M")
except Exception:
return str(ts_value or "")
def _history_preview_text(value: str, limit: int = 180) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip()
if not text or text in EMPTY_TEXT_VALUES:
return "(no text)"
if len(text) <= limit:
return text
return text[: limit - 3].rstrip() + "..."
def _safe_history_direction(raw: str | None) -> str:
value = str(raw or "").strip().lower()
if value in {"incoming", "outgoing"}:
return value
return ""
def _safe_history_days(raw: str | None) -> str:
value = str(raw or "").strip().lower()
if value in {"7", "30", "90", "365", "all"}:
return value
return "30"
def _serialize_availability_spans(spans):
def _value(row, key, default=None):
if isinstance(row, dict):
@@ -837,6 +910,19 @@ def _serialize_messages_with_artifacts(messages):
return serialized
def _render_compose_message_rows(
message_rows, *, show_empty_state=False, empty_message=""
):
return render_to_string(
"partials/compose-message-rows.html",
{
"message_rows": list(message_rows or []),
"show_empty_state": bool(show_empty_state),
"empty_message": str(empty_message or ""),
},
)
def _owner_name(user) -> str:
return user.first_name or user.get_full_name().strip() or user.username or "Me"
@@ -1752,29 +1838,6 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
return profile
def _ensure_codex_profile(user) -> CommandProfile:
profile, _ = CommandProfile.objects.get_or_create(
user=user,
slug="codex",
defaults={
"name": "Codex",
"enabled": True,
"trigger_token": ".codex",
"reply_required": False,
"exact_match_only": False,
"window_scope": "conversation",
"visibility_mode": "status_in_source",
},
)
if not profile.enabled:
profile.enabled = True
profile.save(update_fields=["enabled", "updated_at"])
if str(profile.trigger_token or "").strip() != ".codex":
profile.trigger_token = ".codex"
profile.save(update_fields=["trigger_token", "updated_at"])
return profile
def _toggle_command_for_channel(
*,
user,
@@ -1790,16 +1853,10 @@ def _toggle_command_for_channel(
if not canonical_identifier:
return (False, "missing_identifier")
if slug == "bp":
profile = _ensure_bp_profile_and_actions(user)
elif slug == "codex":
profile = _ensure_codex_profile(user)
else:
profile = (
CommandProfile.objects.filter(user=user, slug=slug).order_by("id").first()
)
if profile is None:
return (False, f"unknown_command:{slug}")
if slug != "bp":
return (False, f"unknown_command:{slug}")
profile = _ensure_bp_profile_and_actions(user)
if not profile.enabled and enabled:
profile.enabled = True
profile.save(update_fields=["enabled", "updated_at"])
@@ -1839,7 +1896,11 @@ def _toggle_command_for_channel(
def _command_options_for_channel(user, service: str, identifier: str) -> list[dict]:
service_key = _default_service(service)
variants = _command_channel_identifier_variants(service_key, identifier)
profiles = list(CommandProfile.objects.filter(user=user).order_by("slug", "id"))
profiles = list(
CommandProfile.objects.filter(user=user, slug__in=COMPOSE_COMMAND_SLUGS).order_by(
"slug", "id"
)
)
by_slug = {str(row.slug or "").strip(): row for row in profiles}
if "bp" not in by_slug:
by_slug["bp"] = CommandProfile(
@@ -1849,14 +1910,6 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
trigger_token=".bp",
enabled=True,
)
if "codex" not in by_slug:
by_slug["codex"] = CommandProfile(
user=user,
slug="codex",
name="Codex",
trigger_token=".codex",
enabled=True,
)
slugs = sorted(by_slug.keys())
options = []
for slug in slugs:
@@ -2998,6 +3051,7 @@ class ComposeWorkspace(LoginRequiredMixin, View):
limit = _safe_limit(request.GET.get("limit") or 40)
initial_widget_url = ""
history_widget_query = {"limit": limit}
if identifier or person is not None:
base = _context_base(request.user, service, identifier, person)
if base["identifier"]:
@@ -3009,32 +3063,242 @@ class ComposeWorkspace(LoginRequiredMixin, View):
initial_widget_url = (
f"{urls['widget_url']}&{urlencode({'limit': limit})}"
)
if base["service"]:
history_widget_query["service"] = base["service"]
if base["person"] is not None:
history_widget_query["person"] = str(base["person"].id)
elif base["identifier"]:
history_widget_query["q"] = str(base["identifier"])
contacts_widget_url = (
f"{reverse('compose_workspace_contacts_widget')}"
f"?{urlencode({'limit': limit})}"
contacts_widget_url = reverse("compose_workspace_contacts_widget")
history_widget_url = (
f"{reverse('compose_workspace_history_widget')}"
f"?{urlencode(history_widget_query)}"
)
context = {
"contacts_widget_url": contacts_widget_url,
"history_widget_url": history_widget_url,
"initial_widget_url": initial_widget_url,
}
return render(request, self.template_name, context)
class ComposeWorkspaceContactsWidget(LoginRequiredMixin, View):
def get(self, request):
limit = _safe_limit(request.GET.get("limit") or 40)
contact_rows = _manual_contact_rows(request.user)
context = {
"title": "Manual Workspace",
"unique": "compose-workspace-contacts",
def _context(self, request):
page = _safe_page(request.GET.get("page") or 1)
search_query = _normalize_contact_search_query(request.GET.get("q"))
total_contacts = _manual_contact_rows(request.user)
is_search_results = bool(search_query)
visible_limit = page * COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE
fetch_limit = visible_limit + COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE
if is_search_results:
matched_rows = _filter_manual_contact_rows(total_contacts, search_query)
source_rows = matched_rows
visible_count = min(len(source_rows), visible_limit)
visible_rows = source_rows[:visible_count]
next_count = min(
COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE,
max(0, len(source_rows) - visible_count),
)
total_matches = len(matched_rows)
result_mode = "search"
else:
source_rows = _recent_manual_contacts(
request.user,
current_service="",
current_identifier="",
current_person=None,
limit=fetch_limit,
)
if not source_rows:
source_rows = total_contacts[:fetch_limit]
result_mode = "all_contacts"
else:
result_mode = "active_chats"
visible_count = min(len(source_rows), visible_limit)
visible_rows = source_rows[:visible_count]
next_count = min(
COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE,
max(0, len(source_rows) - visible_count),
)
total_matches = len(source_rows)
unique = "compose-workspace-contacts"
return {
"title": "Contacts",
"unique": unique,
"window_content": "partials/compose-workspace-contacts-widget.html",
"widget_options": 'gs-w="4" gs-h="14" gs-x="0" gs-y="0" gs-min-w="3"',
"contact_rows": contact_rows,
"limit": limit,
"limit_options": [20, 40, 60, 100, 200],
"manual_icon_class": "fa-solid fa-paper-plane",
"widget_options": 'gs-w="3" gs-h="12" gs-x="0" gs-y="0" gs-min-w="3"',
"contact_rows": visible_rows,
"launcher_form_id": f"{unique}-form",
"search_input_id": f"{unique}-search",
"results_id": f"{unique}-results",
"results_url": f"{reverse('compose_workspace_contacts_widget')}?fragment=results",
"search_query": search_query,
"total_contacts": len(total_contacts),
"total_matches": total_matches,
"visible_count": visible_count,
"has_more": visible_count < len(source_rows),
"next_page": page + 1,
"next_count": next_count,
"result_mode": result_mode,
"is_search_results": is_search_results,
}
def get(self, request):
context = self._context(request)
if str(request.GET.get("fragment") or "").strip().lower() == "results":
return render(
request,
"partials/compose-workspace-contact-results.html",
context,
)
return render(request, "mixins/wm/widget.html", context)
class ComposeWorkspaceHistoryWidget(LoginRequiredMixin, View):
def _history_rows(self, request):
page = _safe_page(request.GET.get("page") or 1)
limit = _safe_limit(request.GET.get("limit") or 40)
search_query = _normalize_contact_search_query(request.GET.get("q"))
service = str(request.GET.get("service") or "").strip().lower()
if service not in {"signal", "whatsapp", "instagram", "xmpp"}:
service = ""
direction = _safe_history_direction(request.GET.get("direction"))
days = _safe_history_days(request.GET.get("days"))
person_scope = None
person_id = str(request.GET.get("person") or "").strip()
if person_id:
person_scope = (
Person.objects.filter(user=request.user, id=person_id)
.only("id", "name")
.first()
)
if person_scope is None:
person_id = ""
queryset = (
Message.objects.filter(user=request.user)
.select_related("session__identifier__person")
.order_by("-ts", "-id")
)
if service:
queryset = queryset.filter(session__identifier__service=service)
if person_id:
queryset = queryset.filter(session__identifier__person_id=person_id)
if direction == "outgoing":
queryset = queryset.filter(custom_author__in=["USER", "BOT"])
elif direction == "incoming":
queryset = queryset.exclude(custom_author__in=["USER", "BOT"])
if days != "all":
cutoff_ts = int(time.time() * 1000) - (int(days) * 24 * 60 * 60 * 1000)
queryset = queryset.filter(ts__gte=cutoff_ts)
if search_query:
for token in [token for token in search_query.split(" ") if token]:
queryset = queryset.filter(
Q(text__icontains=token)
| Q(session__identifier__person__name__icontains=token)
| Q(session__identifier__identifier__icontains=token)
| Q(source_message_id__icontains=token)
)
offset = max(0, (page - 1) * COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE)
rows = list(queryset[offset : offset + COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE + 1])
has_more = len(rows) > COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE
visible_rows = rows[:COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE]
serialized_rows = []
for message in visible_rows:
identifier_obj = getattr(getattr(message, "session", None), "identifier", None)
person = getattr(identifier_obj, "person", None)
service_key = _default_service(getattr(identifier_obj, "service", "") or "")
identifier_value = str(getattr(identifier_obj, "identifier", "") or "").strip()
urls = _compose_urls(
service_key,
identifier_value,
getattr(person, "id", None),
)
serialized_rows.append(
{
"id": str(message.id),
"person_name": str(getattr(person, "name", "") or identifier_value),
"person_id": str(getattr(person, "id", "") or ""),
"identifier": identifier_value,
"service": service_key,
"service_label": _service_label(service_key),
"outgoing": _is_outgoing(message),
"direction_label": "Outgoing" if _is_outgoing(message) else "Incoming",
"display_ts": _format_ts_datetime_label(int(message.ts or 0)),
"text_preview": _history_preview_text(message.text or ""),
"compose_widget_url": urls["widget_url"],
"compose_page_url": urls["page_url"],
}
)
return {
"page": page,
"limit": limit,
"search_query": search_query,
"service": service,
"direction": direction,
"days": days,
"person_scope": person_scope,
"person_scope_id": person_id,
"history_rows": serialized_rows,
"has_more": has_more,
"next_page": page + 1,
"result_start": offset + 1 if serialized_rows else 0,
"result_end": offset + len(serialized_rows),
}
def _context(self, request):
unique = "compose-workspace-history"
rows_context = self._history_rows(request)
return {
"title": "Message History",
"unique": unique,
"window_content": "partials/compose-workspace-history-widget.html",
"widget_options": 'gs-w="8" gs-h="14" gs-x="4" gs-y="0" gs-min-w="4"',
"widget_icon": "fa-solid fa-clock-rotate-left",
"browser_form_id": f"{unique}-form",
"search_input_id": f"{unique}-search",
"service_input_id": f"{unique}-service",
"direction_input_id": f"{unique}-direction",
"days_input_id": f"{unique}-days",
"thread_limit_input_id": f"{unique}-limit",
"results_id": f"{unique}-results",
"results_url": f"{reverse('compose_workspace_history_widget')}?fragment=results",
"service_options": [
("", "All services"),
("signal", "Signal"),
("whatsapp", "WhatsApp"),
("instagram", "Instagram"),
("xmpp", "XMPP"),
],
"direction_options": [
("", "All directions"),
("incoming", "Incoming"),
("outgoing", "Outgoing"),
],
"days_options": [
("7", "Last 7 days"),
("30", "Last 30 days"),
("90", "Last 90 days"),
("365", "Last year"),
("all", "All time"),
],
"thread_limit_options": COMPOSE_THREAD_WINDOW_OPTIONS,
**rows_context,
}
def get(self, request):
context = self._context(request)
if str(request.GET.get("fragment") or "").strip().lower() == "results":
return render(
request,
"partials/compose-workspace-history-results.html",
context,
)
return render(request, "mixins/wm/widget.html", context)
@@ -3343,11 +3607,27 @@ class ComposeWidget(LoginRequiredMixin, View):
if panel_context["person"] is not None
else panel_context["identifier"]
)
widget_key = hashlib.sha1(
(
f"{request.user.pk}:"
f"{panel_context['service']}:"
f"{panel_context['identifier']}:"
f"{getattr(panel_context['person'], 'pk', '')}"
).encode("utf-8")
).hexdigest()[:12]
context = {
"title": f"Manual Chat: {title_name}",
"unique": f"compose-{panel_context['panel_id']}",
"unique": f"compose-widget-{widget_key}",
"window_content": "partials/compose-panel.html",
"widget_control_class": "gia-widget-control-no-scroll",
"widget_options": 'gs-w="6" gs-h="12" gs-x="0" gs-y="0" gs-min-w="4"',
"widget_style_hrefs": [static("css/compose-panel.css")],
"widget_script_srcs": [
static("js/compose-panel-core.js"),
static("js/compose-panel-thread.js"),
static("js/compose-panel-send.js"),
static("js/compose-panel.js"),
],
**panel_context,
}
return render(request, "mixins/wm/widget.html", context)
@@ -3414,8 +3694,10 @@ class ComposeThread(LoginRequiredMixin, View):
)
if newest:
latest_ts = max(latest_ts, int(newest))
serialized_messages = _serialize_messages_with_artifacts(messages)
payload = {
"messages": _serialize_messages_with_artifacts(messages),
"messages": serialized_messages,
"messages_html": _render_compose_message_rows(serialized_messages),
"last_ts": latest_ts,
"typing": get_person_typing_state(
user_id=request.user.id,