From 46465b8a6579a2991f9514f82d4f6ba67f023aea Mon Sep 17 00:00:00 2001 From: Sank34 Date: Wed, 1 Jul 2026 21:40:19 +0300 Subject: [PATCH 01/28] new feature groups --- app/groups/[slug]/page.tsx | 13 + app/groups/page.tsx | 7 + app/invite/[token]/page.tsx | 13 + components/AppSidebar.tsx | 40 +- components/MainWrapper.tsx | 2 + components/MobileDrawer.tsx | 2 + components/Providers.tsx | 10 + components/command/PlatformCommandMenu.tsx | 7 + components/groups/GroupCard.tsx | 136 ++ components/groups/GroupInviteLanding.tsx | 188 ++ components/groups/GroupWorkspace.tsx | 2168 +++++++++++++++++++ components/groups/GroupsDirectory.tsx | 335 +++ components/navigation/TopbarBreadcrumbs.tsx | 1 + components/ui/bubble.tsx | 125 ++ components/ui/marker.tsx | 69 + components/ui/message.tsx | 92 + hooks/useGroupActivity.ts | 200 ++ lib/api.ts | 1274 +++++++++++ lib/i18n.ts | 314 +++ 19 files changed, 4994 insertions(+), 2 deletions(-) create mode 100644 app/groups/[slug]/page.tsx create mode 100644 app/groups/page.tsx create mode 100644 app/invite/[token]/page.tsx create mode 100644 components/groups/GroupCard.tsx create mode 100644 components/groups/GroupInviteLanding.tsx create mode 100644 components/groups/GroupWorkspace.tsx create mode 100644 components/groups/GroupsDirectory.tsx create mode 100644 components/ui/bubble.tsx create mode 100644 components/ui/marker.tsx create mode 100644 components/ui/message.tsx create mode 100644 hooks/useGroupActivity.ts diff --git a/app/groups/[slug]/page.tsx b/app/groups/[slug]/page.tsx new file mode 100644 index 0000000..c582b03 --- /dev/null +++ b/app/groups/[slug]/page.tsx @@ -0,0 +1,13 @@ +import { GroupWorkspace } from "@/components/groups/GroupWorkspace"; + +export const dynamic = "force-dynamic"; + +type GroupPageProps = { + params: Promise<{ slug: string }>; +}; + +export default async function GroupPage({ params }: GroupPageProps) { + const { slug } = await params; + + return ; +} diff --git a/app/groups/page.tsx b/app/groups/page.tsx new file mode 100644 index 0000000..e579e11 --- /dev/null +++ b/app/groups/page.tsx @@ -0,0 +1,7 @@ +import { GroupsDirectory } from "@/components/groups/GroupsDirectory"; + +export const dynamic = "force-dynamic"; + +export default function GroupsPage() { + return ; +} diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx new file mode 100644 index 0000000..9a9f474 --- /dev/null +++ b/app/invite/[token]/page.tsx @@ -0,0 +1,13 @@ +import { GroupInviteLanding } from "@/components/groups/GroupInviteLanding"; + +export const dynamic = "force-dynamic"; + +type InvitePageProps = { + params: Promise<{ token: string }>; +}; + +export default async function InvitePage({ params }: InvitePageProps) { + const { token } = await params; + + return ; +} diff --git a/components/AppSidebar.tsx b/components/AppSidebar.tsx index 7b1a273..b1f6864 100644 --- a/components/AppSidebar.tsx +++ b/components/AppSidebar.tsx @@ -22,6 +22,7 @@ import { import { School, + UsersRound, SquareTerminal, MessageSquare, Search, @@ -43,6 +44,7 @@ import { useEffect, useState, useRef } from "react"; import type { User } from "@supabase/supabase-js"; import { api, type ProfileSummary } from "@/lib/api"; import { useLanguage } from "@/components/LanguageProvider"; +import { useGroupActivity } from "@/hooks/useGroupActivity"; import { useUnreadUpdates } from "@/hooks/useUnreadUpdates"; type NavItemProps = { @@ -50,6 +52,8 @@ type NavItemProps = { icon: LucideIcon; label: string; active: boolean; + badgeCount?: number; + hasActivity?: boolean; }; type SubItemProps = { @@ -62,9 +66,12 @@ function NavItem({ icon: Icon, label, active, + badgeCount = 0, + hasActivity = false, }: NavItemProps) { const { state } = useSidebar(); const collapsed = state === "collapsed"; + const hasBadge = badgeCount > 0; const content = ( ); @@ -133,6 +158,7 @@ export function AppSidebar() { const [user, setUser] = useState(null); const [role, setRole] = useState(null); + const groupActivity = useGroupActivity(user?.id); const [docsOpenOverride, setDocsOpenOverride] = useState(null); const [examplesOpenOverride, setExamplesOpenOverride] = useState(null); @@ -291,6 +317,16 @@ export function AppSidebar() { {user && ( )} + {user && ( + + )} {user && ( )} diff --git a/components/MainWrapper.tsx b/components/MainWrapper.tsx index bf5dac8..ca67f8a 100644 --- a/components/MainWrapper.tsx +++ b/components/MainWrapper.tsx @@ -8,6 +8,8 @@ export function MainWrapper({ children }: { children: React.ReactNode }) { const isFullWidth = pathname === "/editor" || (pathname?.startsWith("/problems/") && pathname !== "/problems") || + (pathname?.startsWith("/groups/") && pathname !== "/groups") || + pathname?.startsWith("/invite/") || pathname?.startsWith("/live/"); useEffect(() => { diff --git a/components/MobileDrawer.tsx b/components/MobileDrawer.tsx index 58ae7e1..98b5f40 100644 --- a/components/MobileDrawer.tsx +++ b/components/MobileDrawer.tsx @@ -22,6 +22,7 @@ import { Sparkles, SquareTerminal, Trophy, + UsersRound, type LucideIcon, } from "lucide-react"; @@ -80,6 +81,7 @@ export function MobileDrawer() { ...(isLoggedIn ? [ { href: "/feed", icon: MessageSquare, label: t("nav.feed") }, + { href: "/groups", icon: UsersRound, label: t("nav.groups") }, { href: "/dashboard", icon: LayoutDashboard, label: t("nav.dashboard") }, { href: "/search", icon: Search, label: t("nav.search") }, ...(isAdmin diff --git a/components/Providers.tsx b/components/Providers.tsx index 14a5d07..5b4f730 100644 --- a/components/Providers.tsx +++ b/components/Providers.tsx @@ -22,6 +22,7 @@ const realtimeInvalidationTargets = [ "contact_messages", "editor-snippets", "notifications", + "groups", "daily-challenge", "daily-challenge-completions", ]; @@ -38,10 +39,19 @@ const realtimeTables = [ "follows", "snippets", "submissions", + "classes", + "class_members", + "assignments", + "assignment_submissions", + "assignment_problem_submissions", "user_achievements", "updates", "contact_messages", "notifications", + "study_groups", + "study_group_members", + "study_group_channels", + "study_group_messages", "daily_challenges", "daily_challenge_completions", ]; diff --git a/components/command/PlatformCommandMenu.tsx b/components/command/PlatformCommandMenu.tsx index 8a9a0e7..92b93fb 100644 --- a/components/command/PlatformCommandMenu.tsx +++ b/components/command/PlatformCommandMenu.tsx @@ -18,6 +18,7 @@ import { Trophy, User, Users, + UsersRound, type LucideIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -171,6 +172,12 @@ export function PlatformCommandMenu({ isAdmin, user }: PlatformCommandMenuProps) label: t("nav.feed"), keywords: ["posts", "social"], }, + { + href: "/groups", + icon: UsersRound, + label: t("nav.groups"), + keywords: ["groups", "study", "community", "discord"], + }, { href: "/search", icon: Search, diff --git a/components/groups/GroupCard.tsx b/components/groups/GroupCard.tsx new file mode 100644 index 0000000..0688f3c --- /dev/null +++ b/components/groups/GroupCard.tsx @@ -0,0 +1,136 @@ +"use client"; + +import Link from "next/link"; +import { Bell, Hash, Lock, MessageCircle, Users } from "lucide-react"; + +import type { StudyGroup } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +type GroupCardProps = { + group: StudyGroup; + joinLabel: string; + memberLabel: string; + pendingLabel: string; + invitedLabel: string; + acceptInviteLabel: string; + privateLabel: string; + publicLabel: string; + openLabel: string; + mentionCount?: number; + hasActivity?: boolean; + mentionLabel: string; + activityLabel: string; + onJoin?: (group: StudyGroup) => void; +}; + +export function GroupCard({ + group, + joinLabel, + memberLabel, + pendingLabel, + invitedLabel, + acceptInviteLabel, + privateLabel, + publicLabel, + openLabel, + mentionCount = 0, + hasActivity = false, + mentionLabel, + activityLabel, + onJoin, +}: GroupCardProps) { + const isMember = group.status === "active"; + const isPending = group.status === "pending"; + const isInvited = group.status === "invited"; + const isPrivate = group.visibility === "private"; + + return ( + + +
+
+
+ +
+ +
+

{group.name}

+

+ + {group.member_count || 0} +

+
+
+ +
+ {mentionCount > 0 ? ( + + + {mentionCount} {mentionLabel} + + ) : hasActivity ? ( + + + + ) : null} + + + {isPrivate ? privateLabel : publicLabel} + +
+
+ + {group.description ? ( +

+ {group.description} +

+ ) : ( +

+ {isPrivate ? privateLabel : publicLabel} +

+ )} + +
+ {isPending ? ( + + + {pendingLabel} + + ) : isMember ? ( + {memberLabel} + ) : isInvited ? ( + {invitedLabel} + ) : ( + + )} + + {isInvited ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/components/groups/GroupInviteLanding.tsx b/components/groups/GroupInviteLanding.tsx new file mode 100644 index 0000000..9bd9c8f --- /dev/null +++ b/components/groups/GroupInviteLanding.tsx @@ -0,0 +1,188 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Hash, Lock, Server, Sparkles, Users } from "lucide-react"; +import { toast } from "sonner"; + +import { api, type StudyGroupInvitePreview } from "@/lib/api"; +import { useLanguage } from "@/components/LanguageProvider"; +import { EmptyState } from "@/components/common/EmptyState"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +type GroupInviteLandingProps = { + token: string; +}; + +export function GroupInviteLanding({ token }: GroupInviteLandingProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + const { t } = useLanguage(); + const [joining, setJoining] = useState(false); + + const inviteQuery = useQuery({ + queryKey: ["group-invite", token], + queryFn: () => api.groups.getInvitePreview(token), + }); + + const preview = inviteQuery.data; + const group = preview?.group || null; + const membership = preview?.membership || null; + const activeMember = membership?.status === "active"; + + async function acceptInvite() { + if (!preview?.userId) { + router.push(`/login?redirect=/invite/${token}`); + return; + } + + if (!group || joining) return; + + setJoining(true); + + try { + const result = await api.groups.acceptInviteLink(token); + toast.success(t("groups.toasts.joined")); + await queryClient.invalidateQueries({ queryKey: ["groups"] }); + router.push(`/groups/${result?.slug || group.slug}`); + } catch (error) { + console.error("Could not accept invite link:", error); + toast.error(t("groups.toasts.joinFailed")); + } finally { + setJoining(false); + } + } + + if (inviteQuery.isLoading) { + return ( +
+
+ + + + +
+
+ ); + } + + if (!group || preview?.expired || preview?.full) { + const title = preview?.expired + ? t("groups.invitePage.expiredTitle") + : preview?.full + ? t("groups.invitePage.fullTitle") + : t("groups.invitePage.unavailableTitle"); + const description = preview?.expired + ? t("groups.invitePage.expiredDescription") + : preview?.full + ? t("groups.invitePage.fullDescription") + : t("groups.invitePage.unavailableDescription"); + + return ( +
+ } + title={title} + description={description} + action={ + + } + /> +
+ ); + } + + return ( +
+
+
+
+ +
+
+ +
+

+ {t("groups.invitePage.eyebrow")} +

+

+ {t("groups.invitePage.title")} +

+

+ {t("groups.invitePage.subtitle")} +

+ +
+
+
+
+

+ {group.name} +

+ + {group.visibility === "private" + ? t("groups.private") + : t("groups.public")} + +
+

+ {group.description || t("groups.workspace.noDescription")} +

+
+
+ + + {preview?.memberCount || 0} {t("groups.invitePage.members")} + + + + {preview?.channelCount || 0} {t("groups.invitePage.channels")} + +
+
+
+ +
+ {activeMember ? ( + + ) : ( + + )} + +
+ +

+ + {activeMember + ? t("groups.invitePage.alreadyMember") + : t("groups.invitePage.invitedHint")} +

+
+
+
+ ); +} diff --git a/components/groups/GroupWorkspace.tsx b/components/groups/GroupWorkspace.tsx new file mode 100644 index 0000000..953b5b6 --- /dev/null +++ b/components/groups/GroupWorkspace.tsx @@ -0,0 +1,2168 @@ +"use client"; + +import Link from "next/link"; +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { flushSync } from "react-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Check, + Copy, + Hash, + LinkIcon, + Lock, + MessageSquare, + Pencil, + Plus, + Radio, + Search, + Send, + SmilePlus, + Settings, + Sparkles, + Trash2, + UserPlus, + Users, + X, +} from "lucide-react"; +import { toast } from "sonner"; + +import { + api, + type MentionCandidate, + type ProfileSummary, + type StudyGroupMessage, + type StudyGroupWorkspace as StudyGroupWorkspaceData, +} from "@/lib/api"; +import { supabase } from "@/lib/supabase"; +import { useLanguage } from "@/components/LanguageProvider"; +import { EmptyState } from "@/components/common/EmptyState"; +import { + markStudyGroupChannelSeen, + markStudyGroupSeen, + useGroupActivity, +} from "@/hooks/useGroupActivity"; +import { UserAvatar } from "@/components/user/UserAvatar"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker"; +import { + Message, + MessageAvatar, + MessageContent, + MessageFooter, + MessageGroup, + MessageHeader, +} from "@/components/ui/message"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; + +type GroupWorkspaceProps = { + slug: string; +}; + +const QUICK_REACTIONS = ["๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐Ÿ”ฅ", "๐ŸŽ‰"]; +const EMOJI_REACTIONS = [ + { emoji: "๐Ÿ˜€", label: "grinning happy smile" }, + { emoji: "๐Ÿ˜ƒ", label: "smile happy" }, + { emoji: "๐Ÿ˜„", label: "laugh happy" }, + { emoji: "๐Ÿ˜", label: "grin" }, + { emoji: "๐Ÿ˜†", label: "laughing" }, + { emoji: "๐Ÿฅน", label: "holding tears" }, + { emoji: "๐Ÿ˜‚", label: "joy laugh tears" }, + { emoji: "๐Ÿคฃ", label: "rolling laugh" }, + { emoji: "๐Ÿ™‚", label: "slight smile" }, + { emoji: "๐Ÿ˜Š", label: "blush smile" }, + { emoji: "๐Ÿ˜‡", label: "angel" }, + { emoji: "๐Ÿฅฐ", label: "love hearts" }, + { emoji: "๐Ÿ˜", label: "heart eyes" }, + { emoji: "๐Ÿคฉ", label: "star eyes" }, + { emoji: "๐Ÿ˜˜", label: "kiss" }, + { emoji: "๐Ÿ˜Ž", label: "cool sunglasses" }, + { emoji: "๐Ÿฅณ", label: "party celebrate" }, + { emoji: "๐Ÿ˜", label: "smirk" }, + { emoji: "๐Ÿ˜…", label: "sweat smile" }, + { emoji: "๐Ÿ˜ญ", label: "cry sob" }, + { emoji: "๐Ÿ˜ข", label: "sad cry" }, + { emoji: "๐Ÿฅฒ", label: "tear smile" }, + { emoji: "๐Ÿ˜ค", label: "triumph" }, + { emoji: "๐Ÿ˜ก", label: "angry" }, + { emoji: "๐Ÿคฏ", label: "mind blown" }, + { emoji: "๐Ÿ˜ณ", label: "flushed" }, + { emoji: "๐Ÿ˜ฑ", label: "scream" }, + { emoji: "๐Ÿ˜ด", label: "sleep" }, + { emoji: "๐Ÿค”", label: "thinking" }, + { emoji: "๐Ÿซก", label: "salute" }, + { emoji: "๐Ÿคจ", label: "raised eyebrow" }, + { emoji: "๐Ÿ™ƒ", label: "upside down" }, + { emoji: "๐Ÿซ ", label: "melting" }, + { emoji: "๐Ÿค", label: "handshake" }, + { emoji: "๐Ÿ‘", label: "clap applause" }, + { emoji: "๐Ÿ™Œ", label: "raised hands" }, + { emoji: "๐Ÿ™", label: "pray thanks" }, + { emoji: "๐Ÿ‘Œ", label: "ok" }, + { emoji: "๐Ÿ‘", label: "thumbs up like" }, + { emoji: "๐Ÿ‘Ž", label: "thumbs down dislike" }, + { emoji: "โœŒ๏ธ", label: "peace" }, + { emoji: "๐Ÿคž", label: "fingers crossed" }, + { emoji: "๐Ÿ’ช", label: "strong flex" }, + { emoji: "๐Ÿซถ", label: "heart hands" }, + { emoji: "๐Ÿ‘€", label: "eyes watch" }, + { emoji: "๐Ÿง ", label: "brain smart" }, + { emoji: "๐Ÿ’ป", label: "laptop code" }, + { emoji: "โŒจ๏ธ", label: "keyboard" }, + { emoji: "๐Ÿ›", label: "bug" }, + { emoji: "๐Ÿš€", label: "rocket launch" }, + { emoji: "๐Ÿ”ฅ", label: "fire hot" }, + { emoji: "โšก", label: "lightning fast" }, + { emoji: "โœจ", label: "sparkles" }, + { emoji: "โญ", label: "star" }, + { emoji: "๐ŸŒŸ", label: "glowing star" }, + { emoji: "๐Ÿ’ซ", label: "dizzy star" }, + { emoji: "๐ŸŽฏ", label: "target" }, + { emoji: "๐Ÿ†", label: "trophy" }, + { emoji: "๐Ÿฅ‡", label: "gold medal" }, + { emoji: "๐ŸŽ‰", label: "party popper" }, + { emoji: "๐ŸŽŠ", label: "confetti" }, + { emoji: "โค๏ธ", label: "red heart love" }, + { emoji: "๐Ÿงก", label: "orange heart" }, + { emoji: "๐Ÿ’›", label: "yellow heart" }, + { emoji: "๐Ÿ’š", label: "green heart" }, + { emoji: "๐Ÿ’™", label: "blue heart" }, + { emoji: "๐Ÿ’œ", label: "purple heart" }, + { emoji: "๐Ÿ–ค", label: "black heart" }, + { emoji: "๐Ÿค", label: "white heart" }, + { emoji: "๐Ÿ’”", label: "broken heart" }, + { emoji: "๐Ÿ’ฏ", label: "hundred perfect" }, + { emoji: "โœ…", label: "check done" }, + { emoji: "โŒ", label: "x wrong" }, + { emoji: "โš ๏ธ", label: "warning" }, + { emoji: "โ—", label: "exclamation" }, + { emoji: "โ“", label: "question" }, + { emoji: "๐Ÿ“", label: "notes write" }, + { emoji: "๐Ÿ“Œ", label: "pin" }, + { emoji: "๐Ÿ“š", label: "books learn" }, + { emoji: "๐Ÿ’ก", label: "idea lightbulb" }, + { emoji: "๐Ÿ”’", label: "lock" }, + { emoji: "๐Ÿ”‘", label: "key" }, + { emoji: "๐Ÿ€", label: "luck clover" }, + { emoji: "โ˜•", label: "coffee" }, + { emoji: "๐Ÿ•", label: "pizza" }, + { emoji: "๐Ÿฐ", label: "cake" }, + { emoji: "๐Ÿข", label: "turtle slow" }, + { emoji: "๐Ÿ", label: "snake python" }, + { emoji: "๐Ÿฑ", label: "cat" }, + { emoji: "๐Ÿถ", label: "dog" }, +]; + +function normalizeChannelSlug(value: string) { + const slug = value + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + + return slug || "channel-name"; +} + +type TypingUser = { + userId: string; + username: string; + avatarUrl: string | null; + lastSeen: number; +}; + +function getJoinedProfile( + value: ProfileSummary | ProfileSummary[] | null | undefined +) { + return Array.isArray(value) ? value[0] : value || null; +} + +type ReactionUser = { + id: string; + username: string; + avatarUrl: string | null; +}; + +function buildReactionGroups( + reactions: StudyGroupMessage["reactions"] = [], + userId: string | null +) { + const grouped = new Map< + string, + { + emoji: string; + count: number; + reactedByMe: boolean; + users: ReactionUser[]; + } + >(); + + for (const reaction of reactions) { + const current = + grouped.get(reaction.emoji) || + { emoji: reaction.emoji, count: 0, reactedByMe: false, users: [] }; + const profile = getJoinedProfile(reaction.profiles); + const username = profile?.username || "user"; + + current.count += 1; + current.reactedByMe = current.reactedByMe || reaction.user_id === userId; + current.users.push({ + id: reaction.user_id, + username, + avatarUrl: profile?.avatar_url || null, + }); + grouped.set(reaction.emoji, current); + } + + return Array.from(grouped.values()); +} + +function MentionPreview({ profile }: { profile: ProfileSummary }) { + const username = profile.username || "user"; + const initial = username.slice(0, 1).toUpperCase(); + + return ( + + + + {profile.avatar_url ? null : initial} + + + + {username} + + + @{username} + + + + + ); +} + +function renderMessageContent( + content: string, + profilesByUsername: Map +) { + const pattern = /(\/live\/[a-f0-9-]+|@[a-zA-Z0-9_-]+)/gi; + const nodes: ReactNode[] = []; + let lastIndex = 0; + + for (const match of content.matchAll(pattern)) { + const token = match[0]; + const index = match.index || 0; + + if (index > lastIndex) { + nodes.push(content.slice(lastIndex, index)); + } + + if (token.startsWith("/live/")) { + nodes.push( + + {token} + + ); + } else { + const username = token.slice(1); + const profile = profilesByUsername.get(username.toLowerCase()); + + nodes.push( + + {profile?.username ? ( + + @{profile.username} + + ) : ( + {token} + )} + {profile ? : null} + + ); + } + + lastIndex = index + token.length; + } + + if (lastIndex < content.length) { + nodes.push(content.slice(lastIndex)); + } + + return <>{nodes}; +} + +export function GroupWorkspace({ slug }: GroupWorkspaceProps) { + const { t, locale } = useLanguage(); + const queryClient = useQueryClient(); + const bottomRef = useRef(null); + const typingChannelRef = useRef | null>(null); + const lastTypingSentRef = useRef(0); + const typingStopTimeoutRef = useRef(null); + const messageInputRef = useRef(null); + const [activeChannelId, setActiveChannelId] = useState(null); + const [message, setMessage] = useState(""); + const [sending, setSending] = useState(false); + const [typingUsers, setTypingUsers] = useState>({}); + const [mentionOpen, setMentionOpen] = useState(false); + const [mentionStart, setMentionStart] = useState(null); + const [mentionQuery, setMentionQuery] = useState(""); + const [mentionActiveIndex, setMentionActiveIndex] = useState(0); + const [editingMessageId, setEditingMessageId] = useState(null); + const [editingMessageText, setEditingMessageText] = useState(""); + const [savingMessageId, setSavingMessageId] = useState(null); + const [deletingMessageId, setDeletingMessageId] = useState(null); + const [emojiSearch, setEmojiSearch] = useState(""); + const [emojiPickerMessageId, setEmojiPickerMessageId] = useState< + string | null + >(null); + const [channelDialogOpen, setChannelDialogOpen] = useState(false); + const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const [newChannelName, setNewChannelName] = useState(""); + const [deletingChannelId, setDeletingChannelId] = useState(null); + const [inviteQuery, setInviteQuery] = useState(""); + const [invitingId, setInvitingId] = useState(null); + const [settingsName, setSettingsName] = useState(""); + const [settingsDescription, setSettingsDescription] = useState(""); + const [settingsVisibility, setSettingsVisibility] = useState<"public" | "private">("public"); + const [savingSettings, setSavingSettings] = useState(false); + const [startingLive, setStartingLive] = useState(false); + const [inviteLink, setInviteLink] = useState(""); + const [creatingInviteLink, setCreatingInviteLink] = useState(false); + + const workspaceQuery = useQuery({ + queryKey: ["groups", slug], + queryFn: () => api.groups.getWorkspace(slug), + }); + + const workspace = workspaceQuery.data; + const group = workspace?.group || null; + const groupId = group?.id || null; + const userId = workspace?.userId || null; + const membership = workspace?.membership || null; + const channels = useMemo( + () => workspace?.channels || [], + [workspace?.channels] + ); + const members = useMemo( + () => workspace?.members || [], + [workspace?.members] + ); + const activeChannel = + channels.find((channel) => channel.id === activeChannelId) || + channels[0] || + null; + const activeMembership = membership?.status === "active"; + const canManage = + membership?.role === "owner" || membership?.role === "admin"; + const canManageChannels = membership?.role === "owner"; + const channelSlugPreview = normalizeChannelSlug(newChannelName); + const groupActivity = useGroupActivity(userId); + const currentMember = useMemo( + () => members.find((member) => member.user_id === userId) || null, + [members, userId] + ); + const currentProfile = currentMember + ? api.groups.getMemberProfile(currentMember) + : null; + const memberIds = useMemo( + () => new Set(members.map((member) => member.user_id)), + [members] + ); + const visibleTypingUsers = useMemo( + () => Object.values(typingUsers), + [typingUsers] + ); + const mentionProfilesByUsername = useMemo(() => { + const map = new Map(); + + for (const member of members) { + const profile = api.groups.getMemberProfile(member); + if (profile?.username) { + map.set(profile.username.toLowerCase(), profile); + } + } + + return map; + }, [members]); + const filteredEmojiReactions = useMemo(() => { + const query = emojiSearch.trim().toLowerCase(); + + if (!query) return EMOJI_REACTIONS; + + return EMOJI_REACTIONS.filter( + (item) => + item.emoji.includes(query) || + item.label.toLowerCase().includes(query) + ); + }, [emojiSearch]); + const closeEmojiPicker = () => { + flushSync(() => { + setEmojiPickerMessageId(null); + setEmojiSearch(""); + }); + }; + const mentionCandidates = useMemo(() => { + const query = mentionQuery.trim().toLowerCase(); + + return members + .map((member) => { + const profile = api.groups.getMemberProfile(member); + const username = profile?.username || ""; + + if (!username || member.user_id === userId) return null; + if (query && !username.toLowerCase().includes(query)) return null; + + return { + id: member.user_id, + username, + avatar_url: profile?.avatar_url || null, + isFollowing: false, + }; + }) + .filter((candidate): candidate is MentionCandidate => Boolean(candidate)) + .slice(0, 8); + }, [members, mentionQuery, userId]); + const membersRef = useRef([]); + + useEffect(() => { + membersRef.current = members; + }, [members]); + + useEffect(() => { + if (!activeChannelId && channels[0]) { + setActiveChannelId(channels[0].id); + } + }, [activeChannelId, channels]); + + useEffect(() => { + if (!group) return; + + setSettingsName(group.name); + setSettingsDescription(group.description || ""); + setSettingsVisibility(group.visibility === "private" ? "private" : "public"); + }, [group]); + + useEffect(() => { + if (!groupId || !userId || !activeMembership) return; + + markStudyGroupSeen(userId, groupId); + + void api.notifications + .markGroupMentionsAsRead(userId, groupId) + .then(() => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ["notifications"] }), + queryClient.invalidateQueries({ queryKey: ["groups", "activity", userId] }), + ]) + ) + .catch((error) => { + console.warn("Could not mark group mentions as read:", error); + }); + }, [activeMembership, groupId, queryClient, userId]); + + const messagesQuery = useQuery({ + queryKey: ["groups", slug, "messages", activeChannel?.id], + queryFn: () => + activeChannel ? api.groups.listMessages(activeChannel.id) : Promise.resolve([]), + enabled: Boolean(activeChannel?.id && activeMembership), + }); + + const latestMessageAt = messagesQuery.data?.at(-1)?.created_at || null; + + useEffect(() => { + if (!groupId || !userId || !activeMembership || !latestMessageAt) return; + + markStudyGroupSeen(userId, groupId); + if (activeChannel?.id) { + markStudyGroupChannelSeen(userId, groupId, activeChannel.id); + } + + void api.notifications + .markGroupMentionsAsRead(userId, groupId) + .then(() => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ["notifications"] }), + queryClient.invalidateQueries({ queryKey: ["groups", "activity", userId] }), + ]) + ) + .catch((error) => { + console.warn("Could not mark live group mentions as read:", error); + }); + }, [ + activeChannel?.id, + activeMembership, + groupId, + latestMessageAt, + queryClient, + userId, + ]); + + const inviteCandidatesQuery = useQuery({ + queryKey: ["groups", slug, "invite-candidates", inviteQuery], + queryFn: () => + userId + ? api.profiles.searchMentionCandidates(userId, inviteQuery, 20) + : Promise.resolve([]), + enabled: Boolean(inviteDialogOpen && userId && canManage), + }); + + const inviteCandidates = (inviteCandidatesQuery.data || []).filter( + (candidate) => !memberIds.has(candidate.id) + ); + + useEffect(() => { + if (!groupId) return; + + const channel = supabase + .channel(`study-group:${groupId}`) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "study_group_channels", + filter: `group_id=eq.${groupId}`, + }, + () => { + void queryClient.invalidateQueries({ queryKey: ["groups", slug] }); + } + ) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "study_group_members", + filter: `group_id=eq.${groupId}`, + }, + () => { + void queryClient.invalidateQueries({ queryKey: ["groups", slug] }); + void queryClient.invalidateQueries({ queryKey: ["groups"] }); + } + ) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "study_group_messages", + filter: `group_id=eq.${groupId}`, + }, + (payload) => { + const eventType = payload.eventType; + const nextMessage = payload.new as StudyGroupMessage | null; + + if (eventType === "INSERT" && nextMessage?.channel_id) { + const member = membersRef.current.find( + (item) => item.user_id === nextMessage.user_id + ); + const profile = member ? api.groups.getMemberProfile(member) : null; + + queryClient.setQueryData( + ["groups", slug, "messages", nextMessage.channel_id], + (current = []) => { + if (current.some((item) => item.id === nextMessage.id)) { + return current; + } + + const withoutOptimisticDuplicate = current.filter( + (item) => + !( + item.id.startsWith("optimistic-") && + item.user_id === nextMessage.user_id && + item.content === nextMessage.content + ) + ); + + return [ + ...withoutOptimisticDuplicate, + { + ...nextMessage, + profiles: profile, + reactions: [], + }, + ]; + } + ); + return; + } + + void queryClient.invalidateQueries({ + queryKey: ["groups", slug, "messages"], + }); + } + ) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "study_group_message_reactions", + filter: `group_id=eq.${groupId}`, + }, + () => { + void queryClient.invalidateQueries({ + queryKey: ["groups", slug, "messages"], + }); + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [groupId, queryClient, slug]); + + useEffect(() => { + if (!groupId || !activeChannel?.id || !userId || !activeMembership) { + setTypingUsers({}); + typingChannelRef.current = null; + return; + } + + setTypingUsers({}); + + const channel = supabase + .channel(`study-group-typing:${groupId}:${activeChannel.id}`) + .on("broadcast", { event: "typing" }, ({ payload }) => { + const typingPayload = payload as { + userId?: string; + username?: string; + avatarUrl?: string | null; + channelId?: string; + isTyping?: boolean; + }; + + if ( + !typingPayload.userId || + typingPayload.userId === userId || + typingPayload.channelId !== activeChannel.id + ) { + return; + } + + const typingUserId = typingPayload.userId; + + setTypingUsers((current) => { + const next = { ...current }; + + if (!typingPayload.isTyping) { + delete next[typingUserId]; + return next; + } + + next[typingUserId] = { + userId: typingUserId, + username: typingPayload.username || "user", + avatarUrl: typingPayload.avatarUrl || null, + lastSeen: Date.now(), + }; + + return next; + }); + }) + .subscribe(); + + typingChannelRef.current = channel; + + const cleanupInterval = window.setInterval(() => { + setTypingUsers((current) => { + const now = Date.now(); + const next = Object.fromEntries( + Object.entries(current).filter( + ([, typingUser]) => now - typingUser.lastSeen < 3500 + ) + ); + + return Object.keys(next).length === Object.keys(current).length + ? current + : next; + }); + }, 1500); + + return () => { + window.clearInterval(cleanupInterval); + if (typingStopTimeoutRef.current) { + window.clearTimeout(typingStopTimeoutRef.current); + } + typingChannelRef.current = null; + supabase.removeChannel(channel); + }; + }, [activeChannel?.id, activeMembership, groupId, userId]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ block: "end" }); + }, [messagesQuery.data?.length, activeChannel?.id, visibleTypingUsers.length]); + + useEffect(() => { + setMentionActiveIndex(0); + }, [mentionQuery, activeChannel?.id]); + + function broadcastTyping(isTyping: boolean) { + if (!typingChannelRef.current || !activeChannel?.id || !userId) return; + + void typingChannelRef.current.send({ + type: "broadcast", + event: "typing", + payload: { + userId, + username: currentProfile?.username || "user", + avatarUrl: currentProfile?.avatar_url || null, + channelId: activeChannel.id, + isTyping, + }, + }); + } + + function updateMentionSearch(value: string, cursor: number) { + const beforeCursor = value.slice(0, cursor); + const match = beforeCursor.match(/(?:^|\s)@([a-zA-Z0-9_-]*)$/); + + if (!match) { + setMentionOpen(false); + setMentionStart(null); + setMentionQuery(""); + return; + } + + const query = match[1] || ""; + setMentionOpen(true); + setMentionStart(cursor - query.length - 1); + setMentionQuery(query); + } + + function handleMessageChange(value: string, cursor = value.length) { + setMessage(value); + updateMentionSearch(value, cursor); + + if (!value.trim()) { + broadcastTyping(false); + return; + } + + const now = Date.now(); + if (now - lastTypingSentRef.current > 1200) { + lastTypingSentRef.current = now; + broadcastTyping(true); + } + + if (typingStopTimeoutRef.current) { + window.clearTimeout(typingStopTimeoutRef.current); + } + + typingStopTimeoutRef.current = window.setTimeout(() => { + broadcastTyping(false); + }, 1800); + } + + function insertMention(candidate: MentionCandidate) { + if (mentionStart === null) return; + + const input = messageInputRef.current; + const cursor = input?.selectionStart ?? message.length; + const before = message.slice(0, mentionStart); + const after = message.slice(cursor); + const nextMessage = `${before}@${candidate.username} ${after}`; + const nextCursor = before.length + candidate.username.length + 2; + + setMessage(nextMessage); + setMentionOpen(false); + setMentionStart(null); + setMentionQuery(""); + + window.requestAnimationFrame(() => { + messageInputRef.current?.focus(); + messageInputRef.current?.setSelectionRange(nextCursor, nextCursor); + }); + } + + function handleMessageKeyDown(event: KeyboardEvent) { + if (mentionOpen) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setMentionActiveIndex((current) => + mentionCandidates.length + ? (current + 1) % mentionCandidates.length + : current + ); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setMentionActiveIndex((current) => + mentionCandidates.length + ? (current - 1 + mentionCandidates.length) % + mentionCandidates.length + : current + ); + return; + } + + if ((event.key === "Enter" || event.key === "Tab") && mentionCandidates.length) { + event.preventDefault(); + insertMention(mentionCandidates[mentionActiveIndex] || mentionCandidates[0]); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + setMentionOpen(false); + return; + } + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void sendMessage(); + } + } + + async function joinGroup() { + if (!group || !userId) return; + + try { + const status = await api.groups.joinGroup( + group.id, + userId, + group.visibility, + locale + ); + toast.success( + status === "pending" + ? t("groups.toasts.requested") + : t("groups.toasts.joined") + ); + await queryClient.invalidateQueries({ queryKey: ["groups", slug] }); + await queryClient.invalidateQueries({ queryKey: ["groups"] }); + } catch (error) { + console.error("Could not join group:", error); + toast.error(t("groups.toasts.joinFailed")); + } + } + + async function sendMessage() { + if (!group || !activeChannel || !userId || !message.trim() || sending) return; + + const content = message.trim(); + const optimisticMessage: StudyGroupMessage = { + id: `optimistic-${Date.now()}`, + group_id: group.id, + channel_id: activeChannel.id, + user_id: userId, + content, + kind: "message", + metadata: { optimistic: true }, + created_at: new Date().toISOString(), + profiles: currentProfile, + reactions: [], + }; + + setMessage(""); + setMentionOpen(false); + setMentionStart(null); + setMentionQuery(""); + broadcastTyping(false); + queryClient.setQueryData( + ["groups", slug, "messages", activeChannel.id], + (current = []) => [...current, optimisticMessage] + ); + setSending(true); + + try { + await api.groups.sendMessage({ + groupId: group.id, + channelId: activeChannel.id, + userId, + content, + locale, + }); + await queryClient.invalidateQueries({ + queryKey: ["groups", slug, "messages", activeChannel.id], + }); + } catch (error) { + console.error("Could not send group message:", error); + queryClient.setQueryData( + ["groups", slug, "messages", activeChannel.id], + (current = []) => + current.filter((item) => item.id !== optimisticMessage.id) + ); + setMessage((current) => current || content); + toast.error(t("groups.toasts.messageFailed")); + } finally { + setSending(false); + } + } + + async function toggleReaction(item: StudyGroupMessage, emoji: string) { + if (!group || !userId) return; + + try { + await api.groups.toggleMessageReaction({ + groupId: group.id, + messageId: item.id, + userId, + emoji, + }); + await queryClient.invalidateQueries({ + queryKey: ["groups", slug, "messages", activeChannel?.id], + }); + } catch (error) { + console.error("Could not update group message reaction:", error); + toast.error(t("groups.toasts.reactionFailed")); + } + } + + function startEditingMessage(item: StudyGroupMessage) { + setEditingMessageId(item.id); + setEditingMessageText(item.content); + } + + function cancelEditingMessage() { + setEditingMessageId(null); + setEditingMessageText(""); + } + + async function saveEditedMessage(item: StudyGroupMessage) { + if (!group || !userId || item.user_id !== userId || savingMessageId) return; + + const content = editingMessageText.trim(); + if (!content) return; + + setSavingMessageId(item.id); + + try { + await api.groups.updateMessage({ + groupId: group.id, + messageId: item.id, + userId, + content, + }); + cancelEditingMessage(); + await queryClient.invalidateQueries({ + queryKey: ["groups", slug, "messages", activeChannel?.id], + }); + toast.success(t("groups.toasts.messageUpdated")); + } catch (error) { + console.error("Could not update group message:", error); + toast.error(t("groups.toasts.messageUpdateFailed")); + } finally { + setSavingMessageId(null); + } + } + + async function deleteMessage(item: StudyGroupMessage) { + if (!group || deletingMessageId) return; + + setDeletingMessageId(item.id); + + try { + await api.groups.deleteMessage({ + groupId: group.id, + messageId: item.id, + }); + await queryClient.invalidateQueries({ + queryKey: ["groups", slug, "messages", activeChannel?.id], + }); + toast.success(t("groups.toasts.messageDeleted")); + } catch (error) { + console.error("Could not delete group message:", error); + toast.error(t("groups.toasts.messageDeleteFailed")); + } finally { + setDeletingMessageId(null); + } + } + + async function createChannel() { + if (!group || !userId || !newChannelName.trim() || !canManageChannels) return; + + try { + const channel = await api.groups.createChannel({ + groupId: group.id, + userId, + name: newChannelName, + }); + setNewChannelName(""); + setChannelDialogOpen(false); + setActiveChannelId(channel.id); + toast.success(t("groups.toasts.channelCreated")); + await queryClient.invalidateQueries({ queryKey: ["groups", slug] }); + } catch (error) { + console.error("Could not create channel:", error); + toast.error(t("groups.toasts.channelFailed")); + } + } + + async function deleteChannel(channelId: string) { + if (!group || !userId || !canManageChannels || deletingChannelId) return; + + setDeletingChannelId(channelId); + + try { + await api.groups.deleteChannel({ + groupId: group.id, + channelId, + userId, + }); + + const nextChannel = channels.find((channel) => channel.id !== channelId); + if (activeChannel?.id === channelId) { + setActiveChannelId(nextChannel?.id || null); + } + + toast.success(t("groups.toasts.channelDeleted")); + await queryClient.invalidateQueries({ queryKey: ["groups", slug] }); + await queryClient.invalidateQueries({ queryKey: ["groups", slug, "messages"] }); + } catch (error) { + console.error("Could not delete channel:", error); + toast.error(t("groups.toasts.channelDeleteFailed")); + } finally { + setDeletingChannelId(null); + } + } + + async function updateSettings() { + if (!group || !settingsName.trim() || savingSettings) return; + + setSavingSettings(true); + + try { + await api.groups.updateGroup({ + groupId: group.id, + name: settingsName, + description: settingsDescription, + visibility: settingsVisibility, + }); + setSettingsDialogOpen(false); + toast.success(t("groups.toasts.updated")); + await queryClient.invalidateQueries({ queryKey: ["groups", slug] }); + await queryClient.invalidateQueries({ queryKey: ["groups"] }); + } catch (error) { + console.error("Could not update group settings:", error); + toast.error(t("groups.toasts.updateFailed")); + } finally { + setSavingSettings(false); + } + } + + async function inviteMember(candidate: MentionCandidate) { + if (!group || !userId || invitingId) return; + + setInvitingId(candidate.id); + + try { + const status = await api.groups.inviteMember({ + groupId: group.id, + inviterId: userId, + inviteeId: candidate.id, + locale, + }); + + toast.success( + status === "active" + ? t("groups.toasts.alreadyMember") + : t("groups.toasts.invited") + ); + await queryClient.invalidateQueries({ queryKey: ["groups", slug] }); + await queryClient.invalidateQueries({ queryKey: ["groups"] }); + } catch (error) { + console.error("Could not invite member:", error); + toast.error(t("groups.toasts.inviteFailed")); + } finally { + setInvitingId(null); + } + } + + async function copyInviteLink(value: string) { + try { + await navigator.clipboard.writeText(value); + toast.success(t("groups.toasts.inviteLinkCopied")); + } catch (error) { + console.error("Could not copy invite link:", error); + } + } + + async function createInviteLink() { + if (!group || !userId || creatingInviteLink) return; + + setCreatingInviteLink(true); + + try { + const invite = await api.groups.createInviteLink({ + groupId: group.id, + userId, + }); + const url = `${window.location.origin}/invite/${invite.token}`; + + setInviteLink(url); + await navigator.clipboard.writeText(url); + toast.success(t("groups.toasts.inviteLinkCreated")); + } catch (error) { + console.error("Could not create invite link:", error); + toast.error(t("groups.toasts.inviteLinkFailed")); + } finally { + setCreatingInviteLink(false); + } + } + + async function startLiveSession() { + if (!group || !activeChannel || !userId || startingLive) return; + + setStartingLive(true); + + try { + const room = await api.groups.startLiveSessionFromChannel({ + groupName: group.name, + groupId: group.id, + channelId: activeChannel.id, + userId, + }); + toast.success(t("groups.toasts.liveStarted")); + await queryClient.invalidateQueries({ + queryKey: ["groups", slug, "messages", activeChannel.id], + }); + window.open(`/live/${room.id}`, "_self"); + } catch (error) { + console.error("Could not start live session:", error); + toast.error(t("groups.toasts.liveFailed")); + } finally { + setStartingLive(false); + } + } + + if (workspaceQuery.isLoading) { + return ( +
+ + + +
+ ); + } + + if (!group) { + return ( + + {t("groups.actions.back")} + + } + /> + ); + } + + const isPrivateBlocked = group.visibility === "private" && !activeMembership; + + if (!activeMembership) { + return ( +
+ : } + title={group.name} + description={ + membership?.status === "pending" + ? t("groups.access.pending") + : membership?.status === "invited" + ? t("groups.access.invited") + : group.description || t("groups.access.description") + } + action={ + !membership || membership.status === "invited" ? ( + + ) : null + } + /> +
+ ); + } + + return ( +
+ + +
+
+
+

+ + {activeChannel?.name || t("groups.workspace.noChannel")} +

+

+ {members.length} {t("groups.workspace.members")} +

+
+ +
+ {canManage && ( + + )} + +
+
+ + +
+ {messagesQuery.isLoading ? ( + <> + + + + ) : messagesQuery.data?.length ? ( + + {messagesQuery.data.map((item) => { + const profile = api.groups.getMessageProfile(item); + const isMine = item.user_id === userId; + const username = profile?.username || "user"; + const profileHref = profile?.username + ? `/u/${profile.username}` + : null; + const isEditing = editingMessageId === item.id; + const canEditMessage = isMine && item.kind !== "system"; + const canDeleteMessage = + item.kind !== "system" && (isMine || canManage); + const reactionGroups = buildReactionGroups( + item.reactions, + userId + ); + const edited = + item.metadata && item.metadata.edited === true; + const time = item.created_at + ? new Date(item.created_at).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : ""; + + if (item.kind === "system") { + return ( + + + + + + + {renderMessageContent( + item.content, + mentionProfilesByUsername + )} + + {time ? ( + + {time} + + ) : null} + + + ); + } + + return ( + + + {profileHref ? ( + + + + ) : ( + + )} + + + + {profileHref ? ( + + {username} + + ) : ( + {username} + )} + +
+ {isEditing ? ( +
+