Skip to content
1 change: 0 additions & 1 deletion public/mockServiceWorker.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable */
/* tslint:disable */

/**
Expand Down
18 changes: 14 additions & 4 deletions src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ const MessagesPage = lazyWithRetry(() => import("@/pages/social/messages/Message
const MessageIdPage = lazyWithRetry(
() => import("@/pages/social/messages/[messageId]/MessageIdPage"),
);
const MessageIdPageGuest = lazy(
() => import("@/pages/social/messages/[messageId]/MessageIdPageGuest"),
);

// You
const LibraryLayout = lazyWithRetry(() => import("@/pages/you/library/LibraryLayout"));
Expand Down Expand Up @@ -229,6 +232,13 @@ const UploadRouter = () => {
return isAuthenticated ? <UploadLayout /> : <GuestNavbarLayout />;
};

const MessageIdPageRouter = () => {
const { isAuthenticated } = useAuthStore();
return (
<Lazy component={isAuthenticated ? MessageIdPage : MessageIdPageGuest} />
);
};

const LogoutPage = () => {
const { logout } = useAuthStore();

Expand Down Expand Up @@ -357,6 +367,10 @@ export const router = createBrowserRouter([
element: <AuthMainLayout />,
errorElement: <ErrorPage />,
children: [
{
path: "messages/:messageId",
element: <MessageIdPageRouter />,
},
{
element: <PrivateRoute />,
children: [
Expand All @@ -370,10 +384,6 @@ export const router = createBrowserRouter([
element: <Lazy component={NotificationsPage} />,
},
{ path: "messages", element: <Lazy component={MessagesPage} /> },
{
path: "messages/:messageId",
element: <Lazy component={MessageIdPage} />,
},

// You
{
Expand Down
29 changes: 26 additions & 3 deletions src/components/MessagingComponents/Chats.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
import { ChatProfile } from "@/components/MessagingComponents/ChatProfile";
import { type Conversation } from "@/services/api/messaging/conversationApi";
import Spinner from "@/components/UI/Spinner";
import type { UIEvent } from "react";

interface ChatsProps {
conversations: Conversation[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
error: string | null;
activeConversationId: string | null;
onSelect: (conversation: Conversation) => void;
onLoadMore: () => void;
}

export function Chats({
conversations,
loading,
loadingMore,
hasMore,
error,
activeConversationId,
onSelect,
onLoadMore,
}: ChatsProps) {
const handleScroll = (e: UIEvent<HTMLDivElement>) => {
if (!hasMore || loadingMore) return;

const el = e.currentTarget;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;

if (distanceFromBottom <= 120) {
onLoadMore();
}
};

// ── Loading ──────────────────────────────────────────────────────────────
if (loading) {
return <Spinner />;
return (
<div className="flex flex-1 items-center justify-center">
<Spinner />
</div>
);
}

// ── Error ────────────────────────────────────────────────────────────────
Expand All @@ -48,7 +67,11 @@ export function Chats({

// ── List ─────────────────────────────────────────────────────────────────
return (
<div data-test="chat-list" className="flex flex-col width-full">
<div
data-test="chat-list"
onScroll={handleScroll}
className="flex flex-1 min-h-0 flex-col overflow-y-auto width-full"
>
{conversations.map((conv) => (
<ChatProfile
key={conv.id}
Expand All @@ -65,4 +88,4 @@ export function Chats({
)}
</div>
);
}
}
84 changes: 59 additions & 25 deletions src/components/MessagingComponents/MessageBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ interface MessageInputProps {
onIsEmptyChange?: (isEmpty: boolean) => void
onEmbedsResolved?: (embeds: ResolvedEmbed[]) => void
hasError?: boolean
externalEmbeds?: ResolvedEmbed[]
onSubmit?: () => void
}

const URL_REGEX = /https?:\/\/[^\s"'<>]+/g
Expand All @@ -21,18 +23,34 @@ export function MessageBox({
onIsEmptyChange,
onEmbedsResolved,
hasError,
externalEmbeds,
onSubmit,
}: MessageInputProps) {
const [value, setValue] = useState("")
const [embeds, setEmbeds] = useState<ResolvedEmbed[]>([])
const [internalEmbeds, setInternalEmbeds] = useState<ResolvedEmbed[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const resolvedUrlsRef = useRef<Map<string, ResolvedEmbed | null>>(new Map())
const controlled = externalEmbeds !== undefined
const currentEmbeds = externalEmbeds ?? internalEmbeds

const mergeEmbeds = (base: ResolvedEmbed[], next: ResolvedEmbed[]) => {
const map = new Map<string, ResolvedEmbed>()
for (const embed of base) {
map.set(`${embed.type}:${embed.id}`, embed)
}
for (const embed of next) {
map.set(`${embed.type}:${embed.id}`, embed)
}
return Array.from(map.values())
}

const stripEmbedUrls = (text: string, activeEmbeds: ResolvedEmbed[]) => {
let stripped = text
// Collect all unique sourceUrls and remove each occurrence once per embed
// (same URL twice = removed twice)
const urlCounts = new Map<string, number>()
for (const embed of activeEmbeds) {
if (!embed.sourceUrl) continue
urlCounts.set(embed.sourceUrl, (urlCounts.get(embed.sourceUrl) ?? 0) + 1)
}
for (const [url, count] of urlCounts.entries()) {
Expand All @@ -47,25 +65,30 @@ export function MessageBox({

// FIX 1: remove by index so duplicate URLs only remove one entry at a time
const removeEmbedAt = (indexToRemove: number) => {
const embedToRemove = embeds[indexToRemove]

// Remove one occurrence of the URL from the textarea
let removed = false
const newValue = value.replace(
new RegExp(embedToRemove.sourceUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
(match) => {
if (!removed) { removed = true; return "" }
return match
}
).replace(/\s{2,}/g, " ").trim()
const embedToRemove = currentEmbeds[indexToRemove]

let newValue = value
if (embedToRemove.sourceUrl) {
let removed = false
newValue = value.replace(
new RegExp(embedToRemove.sourceUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
(match) => {
if (!removed) { removed = true; return "" }
return match
}
).replace(/\s{2,}/g, " ").trim()
}

setValue(newValue)

const next = embeds.filter((_, i) => i !== indexToRemove)
const next = currentEmbeds.filter((_, i) => i !== indexToRemove)
const cleanText = stripEmbedUrls(newValue, next)
onValueChange?.(cleanText)
onIsEmptyChange?.(cleanText.trim() === "" && next.length === 0)
setEmbeds(next)

if (!controlled) {
setInternalEmbeds(next)
}
onEmbedsResolved?.(next)
}

Expand All @@ -79,17 +102,19 @@ export function MessageBox({
if (!matches) {
const cleanText = text.trim()
onValueChange?.(cleanText)
onIsEmptyChange?.(cleanText === "")
setEmbeds([])
onEmbedsResolved?.([])
onIsEmptyChange?.(cleanText === "" && currentEmbeds.length === 0)
if (!controlled) {
setInternalEmbeds([])
}
onEmbedsResolved?.(currentEmbeds)
return
}

// Pass text-without-urls upstream immediately for validation
// (will be recalculated after resolve settles)
const tempClean = matches.reduce((acc, url) => acc.replace(url, ""), text).replace(/\s{2,}/g, " ").trim()
onValueChange?.(tempClean)
onIsEmptyChange?.(tempClean === "" && embeds.length === 0)
onIsEmptyChange?.(tempClean === "" && currentEmbeds.length === 0)

debounceRef.current = setTimeout(async () => {
const results = await Promise.all(
Expand Down Expand Up @@ -151,12 +176,15 @@ export function MessageBox({
)

const validEmbeds = results.filter((r): r is ResolvedEmbed => r !== null)
setEmbeds(validEmbeds)
onEmbedsResolved?.(validEmbeds)
const nextEmbeds = controlled ? mergeEmbeds(currentEmbeds, validEmbeds) : validEmbeds
if (!controlled) {
setInternalEmbeds(validEmbeds)
}
onEmbedsResolved?.(nextEmbeds)

const cleanText = stripEmbedUrls(text, validEmbeds)
const cleanText = stripEmbedUrls(text, nextEmbeds)
onValueChange?.(cleanText)
onIsEmptyChange?.(cleanText === "" && validEmbeds.length === 0)
onIsEmptyChange?.(cleanText === "" && nextEmbeds.length === 0)
}, 600)
}

Expand All @@ -167,15 +195,21 @@ export function MessageBox({
data-test="message-input"
value={value}
onChange={handleChange}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSubmit?.()
}
}}
rows={4}
className={`w-full resize-y bg-[#2a2a2a] border text-white text-sm px-3 py-2 rounded focus:outline-none caret-[#f50] ${
hasError ? "border-red-500" : "border-[#444] focus:border-[#666]"
}`}
/>

{embeds.length > 0 && (
{currentEmbeds.length > 0 && (
<div className="flex flex-col mt-1">
{embeds.map((embed, index) => {
{currentEmbeds.map((embed, index) => {
const props =
embed.type === "track"
? {
Expand All @@ -201,4 +235,4 @@ export function MessageBox({
)}
</div>
)
}
}
Loading
Loading