Add conversation pinning: pinned chats stay at top of sidebar
Build & Auto-Repair / build (push) Has been cancelled

This commit is contained in:
JCBSComputer
2026-07-02 16:09:07 +02:00
parent 4c732c850f
commit 211d026fb8
4 changed files with 78 additions and 6 deletions
@@ -0,0 +1,2 @@
-- Add pinned_at column to conversation_participants for pinning chats
ALTER TABLE conversation_participants ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
+42 -4
View File
@@ -18,6 +18,7 @@ import {
Search, Send, Phone, MoreHorizontal, Paperclip,
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
Pin, PinOff,
} from "lucide-react"
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
import {
@@ -211,8 +212,15 @@ const formatPreviewContent = (content: string) => {
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
const filteredConversations = useMemo(() => {
if (!searchQuery.trim()) return conversations
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
let list = conversations
if (searchQuery.trim()) {
list = conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
}
return [...list].sort((a, b) => {
if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1
return 0
})
}, [conversations, searchQuery])
// Fetch conversations from API
@@ -776,6 +784,23 @@ const formatPreviewContent = (content: string) => {
const isImageFile = (file: File) => file.type.startsWith("image/")
const handleTogglePin = async (convId: string, pinned: boolean) => {
try {
const res = await fetch(`/api/conversations/${convId}/pin`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pin: !pinned }),
})
if (res.ok) {
setConversations((prev) =>
prev.map((c) => (c.id === convId ? { ...c, pinned: !pinned } : c))
)
}
} catch {
console.warn("Failed to toggle pin")
}
}
const getDisplayContent = (content: string) => {
try {
const parsed = JSON.parse(content)
@@ -827,7 +852,7 @@ const formatPreviewContent = (content: string) => {
})
}}
className={cn(
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative",
"w-full group flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative",
isActive && "bg-muted"
)}
>
@@ -837,8 +862,21 @@ const formatPreviewContent = (content: string) => {
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{conv.pinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground rotate-45" />}
<span className="text-sm font-medium truncate">{person.name}</span>
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleTogglePin(conv.id, conv.pinned) }}
className="h-5 w-5 flex items-center justify-center rounded hover:bg-muted-foreground/20 transition-colors opacity-0 group-hover:opacity-100"
title={conv.pinned ? "Unpin" : "Pin"}
>
{conv.pinned ? <PinOff className="h-3 w-3 text-muted-foreground" /> : <Pin className="h-3 w-3 text-muted-foreground rotate-45" />}
</button>
<span className="text-xs text-muted-foreground">{conv.lastMessageTime}</span>
</div>
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
</div>
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json()
const pin = body.pin === true
await query(
`UPDATE conversation_participants
SET pinned_at = $1
WHERE conversation_id = $2 AND user_id = $3`,
[pin ? new Date().toISOString() : null, id, user.id],
)
return NextResponse.json({ pinned: pin })
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to toggle pin"
console.error("Pin conversation error:", message)
return NextResponse.json({ error: message }, { status: 500 })
}
}
+3 -1
View File
@@ -13,6 +13,7 @@ export async function GET() {
c.id,
c.updated_at,
cp_me.last_read_at,
cp_me.pinned_at,
u.id AS other_user_id,
u.first_name || ' ' || u.last_name AS other_user_name,
u.email AS other_user_email,
@@ -28,7 +29,7 @@ export async function GET() {
WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
)
ORDER BY c.updated_at DESC
ORDER BY cp_me.pinned_at DESC NULLS LAST, c.updated_at DESC
LIMIT 50`,
[user.id],
)
@@ -36,6 +37,7 @@ export async function GET() {
const conversations = result.rows.map((row: any) => ({
id: row.id,
updatedAt: row.updated_at,
pinned: row.pinned_at !== null,
otherUser: {
id: row.other_user_id,
name: row.other_user_name,