chat: pin messages via context menu and align pinned bubble layout
This commit is contained in:
@@ -2185,6 +2185,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("popstate", popstateHandler);
|
window.addEventListener("popstate", popstateHandler);
|
||||||
|
window.addEventListener("pointerdown", onWindowPointerDownForCommPinMenu);
|
||||||
|
window.addEventListener("keydown", onWindowKeyDownForCommPinMenu);
|
||||||
|
|
||||||
if (!authResolved.value) {
|
if (!authResolved.value) {
|
||||||
void bootstrapSession().finally(() => {
|
void bootstrapSession().finally(() => {
|
||||||
@@ -2224,6 +2226,8 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener("popstate", popstateHandler);
|
window.removeEventListener("popstate", popstateHandler);
|
||||||
popstateHandler = null;
|
popstateHandler = null;
|
||||||
}
|
}
|
||||||
|
window.removeEventListener("pointerdown", onWindowPointerDownForCommPinMenu);
|
||||||
|
window.removeEventListener("keydown", onWindowKeyDownForCommPinMenu);
|
||||||
if (lifecycleClock) {
|
if (lifecycleClock) {
|
||||||
clearInterval(lifecycleClock);
|
clearInterval(lifecycleClock);
|
||||||
lifecycleClock = null;
|
lifecycleClock = null;
|
||||||
@@ -3398,6 +3402,17 @@ const commSending = ref(false);
|
|||||||
const commRecording = ref(false);
|
const commRecording = ref(false);
|
||||||
const commComposerMode = ref<"message" | "planned" | "logged" | "document">("message");
|
const commComposerMode = ref<"message" | "planned" | "logged" | "document">("message");
|
||||||
const commQuickMenuOpen = ref(false);
|
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 commEventSaving = ref(false);
|
||||||
const commEventError = ref("");
|
const commEventError = ref("");
|
||||||
const commEventMode = ref<"planned" | "logged">("planned");
|
const commEventMode = ref<"planned" | "logged">("planned");
|
||||||
@@ -3435,6 +3450,7 @@ watch(selectedCommThreadId, () => {
|
|||||||
commDraft.value = "";
|
commDraft.value = "";
|
||||||
commComposerMode.value = "message";
|
commComposerMode.value = "message";
|
||||||
commQuickMenuOpen.value = false;
|
commQuickMenuOpen.value = false;
|
||||||
|
commPinContextMenu.value = { open: false, x: 0, y: 0, entry: null };
|
||||||
commEventError.value = "";
|
commEventError.value = "";
|
||||||
commDocumentForm.value = { title: "" };
|
commDocumentForm.value = { title: "" };
|
||||||
eventCloseOpen.value = {};
|
eventCloseOpen.value = {};
|
||||||
@@ -3544,11 +3560,19 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectedCommPinnedStream = computed(() => {
|
const selectedCommPinnedStream = computed(() => {
|
||||||
const pins = selectedCommPins.value.map((pin) => ({
|
const pins = selectedCommPins.value.map((pin) => {
|
||||||
id: `pin-${pin.id}`,
|
const normalizedText = normalizePinText(stripPinnedPrefix(pin.text));
|
||||||
kind: "pin" as const,
|
const sourceItem =
|
||||||
text: pin.text,
|
[...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) => {
|
const rank = (phase: EventLifecyclePhase) => {
|
||||||
if (phase === "awaiting_outcome") return 0;
|
if (phase === "awaiting_outcome") return 0;
|
||||||
@@ -3604,6 +3628,66 @@ function entryPinText(entry: any): string {
|
|||||||
return normalizePinText(entry.item?.text || "");
|
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 }) {
|
function canManuallyCloseEvent(entry: { kind: string; event?: CalendarEvent; phase?: EventLifecyclePhase }) {
|
||||||
if (entry.kind !== "eventLifecycle" || !entry.event) return false;
|
if (entry.kind !== "eventLifecycle" || !entry.event) return false;
|
||||||
return !isEventFinalStatus(entry.event.isArchived);
|
return !isEventFinalStatus(entry.event.isArchived);
|
||||||
@@ -5637,12 +5721,27 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
<div
|
<div
|
||||||
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
|
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
@contextmenu.prevent="togglePinForEntry(entry)"
|
@contextmenu.prevent="openCommPinContextMenu($event, entry)"
|
||||||
>
|
>
|
||||||
<div v-if="entry.kind === 'pin'" class="flex justify-center">
|
<div
|
||||||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
|
v-if="entry.kind === 'pin'"
|
||||||
<p class="text-sm text-base-content/85">{{ stripPinnedPrefix(entry.text) }}</p>
|
class="flex"
|
||||||
</article>
|
:class="entry.sourceItem ? (entry.sourceItem.direction === 'out' ? 'justify-end' : 'justify-start') : 'justify-center'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-w-[88%] rounded-xl border border-base-300 p-3"
|
||||||
|
:class="entry.sourceItem?.direction === 'out' ? 'bg-base-200' : 'bg-base-100'"
|
||||||
|
>
|
||||||
|
<p class="text-sm">{{ stripPinnedPrefix(entry.text) }}</p>
|
||||||
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
|
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
|
||||||
|
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||||||
|
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>{{ entry.sourceItem ? formatStamp(entry.sourceItem.at) : "Pinned" }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="entry.kind === 'call'" class="flex justify-center">
|
<div v-else-if="entry.kind === 'call'" class="flex justify-center">
|
||||||
@@ -5845,6 +5944,21 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="commPinContextMenu.open"
|
||||||
|
class="comm-pin-context-menu"
|
||||||
|
:style="{ left: `${commPinContextMenu.x}px`, top: `${commPinContextMenu.y}px` }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="comm-pin-context-menu-item"
|
||||||
|
:disabled="commPinToggling"
|
||||||
|
@click="applyCommPinContextAction"
|
||||||
|
>
|
||||||
|
{{ commPinContextActionLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sticky bottom-0 z-10 mt-0 border-t border-base-300 bg-base-100/95 px-3 pt-3 backdrop-blur">
|
<div class="sticky bottom-0 z-10 mt-0 border-t border-base-300 bg-base-100/95 px-3 pt-3 backdrop-blur">
|
||||||
<div class="absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-1/2">
|
<div class="absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-1/2">
|
||||||
<div
|
<div
|
||||||
@@ -6923,6 +7037,40 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comm-pin-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 60;
|
||||||
|
min-width: 128px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-base-100);
|
||||||
|
box-shadow: 0 16px 30px rgba(11, 23, 46, 0.22);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-pin-context-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
padding: 7px 9px;
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-pin-context-menu-item:hover:not(:disabled) {
|
||||||
|
background: color-mix(in oklab, var(--color-base-200) 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-pin-context-menu-item:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.comm-event-modal {
|
.comm-event-modal {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user