Reimplement compose and add tiling windows
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user