{{ stripPinnedPrefix(entry.text) }}
-From 21d6e440e39bf64dae6b34d07f5e5229ff3af183 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:05:59 +0700 Subject: [PATCH] chat: pin messages via context menu and align pinned bubble layout --- frontend/app/pages/index.vue | 168 ++++++++++++++++++++++++++++++++--- 1 file changed, 158 insertions(+), 10 deletions(-) diff --git a/frontend/app/pages/index.vue b/frontend/app/pages/index.vue index 9b476dc..7fc90c2 100644 --- a/frontend/app/pages/index.vue +++ b/frontend/app/pages/index.vue @@ -2185,6 +2185,8 @@ onMounted(() => { } }; window.addEventListener("popstate", popstateHandler); + window.addEventListener("pointerdown", onWindowPointerDownForCommPinMenu); + window.addEventListener("keydown", onWindowKeyDownForCommPinMenu); if (!authResolved.value) { void bootstrapSession().finally(() => { @@ -2224,6 +2226,8 @@ onBeforeUnmount(() => { window.removeEventListener("popstate", popstateHandler); popstateHandler = null; } + window.removeEventListener("pointerdown", onWindowPointerDownForCommPinMenu); + window.removeEventListener("keydown", onWindowKeyDownForCommPinMenu); if (lifecycleClock) { clearInterval(lifecycleClock); lifecycleClock = null; @@ -3398,6 +3402,17 @@ const commSending = ref(false); const commRecording = ref(false); const commComposerMode = ref<"message" | "planned" | "logged" | "document">("message"); const commQuickMenuOpen = ref(false); +const commPinContextMenu = ref<{ + open: boolean; + x: number; + y: number; + entry: any | null; +}>({ + open: false, + x: 0, + y: 0, + entry: null, +}); const commEventSaving = ref(false); const commEventError = ref(""); const commEventMode = ref<"planned" | "logged">("planned"); @@ -3435,6 +3450,7 @@ watch(selectedCommThreadId, () => { commDraft.value = ""; commComposerMode.value = "message"; commQuickMenuOpen.value = false; + commPinContextMenu.value = { open: false, x: 0, y: 0, entry: null }; commEventError.value = ""; commDocumentForm.value = { title: "" }; eventCloseOpen.value = {}; @@ -3544,11 +3560,19 @@ watch( ); const selectedCommPinnedStream = computed(() => { - const pins = selectedCommPins.value.map((pin) => ({ - id: `pin-${pin.id}`, - kind: "pin" as const, - text: pin.text, - })); + const pins = selectedCommPins.value.map((pin) => { + const normalizedText = normalizePinText(stripPinnedPrefix(pin.text)); + const sourceItem = + [...visibleThreadItems.value] + .filter((item) => normalizePinText(item.text) === normalizedText) + .sort((a, b) => b.at.localeCompare(a.at))[0] ?? null; + return { + id: `pin-${pin.id}`, + kind: "pin" as const, + text: pin.text, + sourceItem, + }; + }); const rank = (phase: EventLifecyclePhase) => { if (phase === "awaiting_outcome") return 0; @@ -3604,6 +3628,66 @@ function entryPinText(entry: any): string { return normalizePinText(entry.item?.text || ""); } +function closeCommPinContextMenu() { + commPinContextMenu.value = { + open: false, + x: 0, + y: 0, + entry: null, + }; +} + +function openCommPinContextMenu(event: MouseEvent, entry: any) { + const text = entryPinText(entry); + if (!text) return; + const menuWidth = 136; + const menuHeight = 46; + const padding = 8; + const maxX = Math.max(padding, window.innerWidth - menuWidth - padding); + const maxY = Math.max(padding, window.innerHeight - menuHeight - padding); + const x = Math.min(maxX, Math.max(padding, event.clientX)); + const y = Math.min(maxY, Math.max(padding, event.clientY)); + commPinContextMenu.value = { + open: true, + x, + y, + entry, + }; +} + +function isPinnedEntry(entry: any) { + const contact = selectedCommThread.value?.contact ?? ""; + const text = entryPinText(entry); + return isPinnedText(contact, text); +} + +const commPinContextActionLabel = computed(() => { + const entry = commPinContextMenu.value.entry; + if (!entry) return "Pin"; + return isPinnedEntry(entry) ? "Unpin" : "Pin"; +}); + +async function applyCommPinContextAction() { + const entry = commPinContextMenu.value.entry; + if (!entry) return; + closeCommPinContextMenu(); + await togglePinForEntry(entry); +} + +function onWindowPointerDownForCommPinMenu(event: PointerEvent) { + if (!commPinContextMenu.value.open) return; + const target = event.target as HTMLElement | null; + if (target?.closest(".comm-pin-context-menu")) return; + closeCommPinContextMenu(); +} + +function onWindowKeyDownForCommPinMenu(event: KeyboardEvent) { + if (!commPinContextMenu.value.open) return; + if (event.key === "Escape") { + closeCommPinContextMenu(); + } +} + function canManuallyCloseEvent(entry: { kind: string; event?: CalendarEvent; phase?: EventLifecyclePhase }) { if (entry.kind !== "eventLifecycle" || !entry.event) return false; return !isEventFinalStatus(entry.event.isArchived); @@ -5637,12 +5721,27 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
{{ stripPinnedPrefix(entry.text) }}
-{{ stripPinnedPrefix(entry.text) }}
++ + + + {{ entry.sourceItem ? formatStamp(entry.sourceItem.at) : "Pinned" }} +
+