Safari • iPhone
@@ -121,7 +121,7 @@ export default function SecurityPage() {
{/* Two Factor */}
-
+
Two-Factor Authentication
diff --git a/apps/frontend/src/app/zone/_components/chat/EmojiPicker.tsx b/apps/frontend/src/app/zone/_components/chat/EmojiPicker.tsx
new file mode 100644
index 0000000..bef468a
--- /dev/null
+++ b/apps/frontend/src/app/zone/_components/chat/EmojiPicker.tsx
@@ -0,0 +1,118 @@
+'use client'
+
+import { useState, useEffect, useRef, useCallback, Suspense, lazy } from 'react'
+import { X } from 'lucide-react'
+import { cn } from '@openchat/lib'
+
+const Picker = lazy(() => import('@emoji-mart/react'))
+
+function PickerFallback() {
+ return (
+
+ Loading...
+
+ )
+}
+
+type EmojiPickerProps = {
+ onSelect: (emoji: string) => void
+ onClose: () => void
+ className?: string
+}
+
+export default function EmojiPicker({ onSelect, onClose, className }: EmojiPickerProps) {
+ const [isMounted, setIsMounted] = useState(false)
+ const pickerRef = useRef(null)
+
+ useEffect(() => {
+ setIsMounted(true)
+ }, [])
+
+ const handleClickOutside = useCallback((event: MouseEvent) => {
+ const target = event.target as Node
+ if (pickerRef.current && !pickerRef.current.contains(target)) {
+ onClose()
+ }
+ }, [onClose])
+
+ const handleKeyDown = useCallback((event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose()
+ }
+ }, [onClose])
+
+ useEffect(() => {
+ if (!isMounted) return
+
+ document.addEventListener('mousedown', handleClickOutside)
+ document.addEventListener('keydown', handleKeyDown)
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside)
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [isMounted, handleClickOutside, handleKeyDown])
+
+ if (!isMounted) return null
+
+ return (
+
+
+
+
+
}>
+
{
+ const module = await import('@emoji-mart/data')
+ return module.default
+ }}
+ onEmojiClick={(emoji: { native: string }) => {
+ onSelect(emoji.native)
+ onClose()
+ }}
+ theme="dark"
+ previewPosition="none"
+ skinTonePosition="none"
+ set="native"
+ perLine={8}
+ emojiSize={22}
+ emojiButtonSize={32}
+ initialCategory="people"
+ I18n={{
+ search: 'Search...',
+ categories: {
+ activity: 'Activity',
+ custom: 'Custom',
+ flags: 'Flags',
+ foods: 'Food',
+ nature: 'Nature',
+ objects: 'Objects',
+ people: 'Smileys',
+ symbols: 'Symbols',
+ travel: 'Travel'
+ }
+ }}
+ style={{
+ backgroundColor: 'hsl(var(--background) / 1)',
+ borderRadius: '14px',
+ border: '1px solid hsl(var(--border) / 1)',
+ width: '320px'
+ }}
+ />
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/frontend/src/app/zone/_components/chat/GifPicker.tsx b/apps/frontend/src/app/zone/_components/chat/GifPicker.tsx
index 6a7b52b..150bb61 100644
--- a/apps/frontend/src/app/zone/_components/chat/GifPicker.tsx
+++ b/apps/frontend/src/app/zone/_components/chat/GifPicker.tsx
@@ -7,10 +7,12 @@ import { cn } from '@openchat/lib'
export default function GifPicker({
onSelect,
- onClose
+ onClose,
+ autoClose = true
}: {
onSelect: (url: string) => void,
onClose: () => void
+ autoClose?: boolean
}) {
const [search, setSearch] = useState('')
diff --git a/apps/frontend/src/app/zone/_components/voice/VoiceParticipants.tsx b/apps/frontend/src/app/zone/_components/voice/VoiceParticipants.tsx
index 758adda..14bfdb0 100644
--- a/apps/frontend/src/app/zone/_components/voice/VoiceParticipants.tsx
+++ b/apps/frontend/src/app/zone/_components/voice/VoiceParticipants.tsx
@@ -1,12 +1,12 @@
'use client'
-import { Avatar, AvatarFallback } from 'packages/ui'
-import { getAvatarUrl, cn } from '@openchat/lib'
+import { cn } from '@openchat/lib'
import {
type ChannelVoiceParticipant,
useCallStore,
} from '@/app/stores/call-store'
-import { Headphones, MicOff, HeadphoneOff } from 'lucide-react'
+import { MicOff, HeadphoneOff } from 'lucide-react'
+import { UserAvatar } from '@/components/UserAvatar'
export default function VoiceParticipants({
participants,
@@ -19,7 +19,6 @@ export default function VoiceParticipants({
return (
{participants.map((participant) => {
- const avatarUrl = getAvatarUrl(participant.avatar)
const isSpeaking = speakingUsers.has(participant.userId)
return (
@@ -28,27 +27,18 @@ export default function VoiceParticipants({
className="group flex items-center gap-2 rounded-md px-2 py-1 hover:bg-white/5 cursor-pointer transition-colors"
>
-
- {avatarUrl ? (
-
- ) : (
-
- {participant.username?.[0]?.toUpperCase() ??
- 'U'}
-
- )}
-
+ fallbackText={participant.username}
+ fallbackClassName="bg-white/10 text-[10px] font-bold text-zinc-300"
+ />
{isSpeaking && (
)}
diff --git a/apps/frontend/src/app/zone/_components/zones/CreateZoneModal.tsx b/apps/frontend/src/app/zone/_components/zones/CreateZoneModal.tsx
index 7dc2332..f6c1dd8 100644
--- a/apps/frontend/src/app/zone/_components/zones/CreateZoneModal.tsx
+++ b/apps/frontend/src/app/zone/_components/zones/CreateZoneModal.tsx
@@ -2,6 +2,7 @@
import { useState, useRef, useEffect } from "react"
import { Camera, Plus, X } from "lucide-react"
+import { toast } from "sonner"
type ModalProps = {
open: boolean
@@ -45,6 +46,10 @@ export function CreateZoneModal({ open, onClose, onCreate }: ModalProps) {
setError("Zone name is required")
return
}
+ if (name.length > 50) {
+ setError("Zone name must be 50 characters or less")
+ return
+ }
setIsSubmitting(true)
onCreate(name, avatar)
onClose()
@@ -148,13 +153,21 @@ export function CreateZoneModal({ open, onClose, onCreate }: ModalProps) {
disabled={isSubmitting}
onChange={(e) => {
const file = e.target.files?.[0]
- if (file) setAvatar(file)
+ if (file) {
+ const MAX_SIZE = 2 * 1024 * 1024
+ if (file.size > MAX_SIZE) {
+ toast.error("File too large. Maximum size is 2MB")
+ return
+ }
+ setAvatar(file)
+ }
}}
/>
Upload Avatar
+
JPG, PNG up to 2MB
diff --git a/apps/frontend/src/app/zone/_components/zones/ZoneDashboardSheet.tsx b/apps/frontend/src/app/zone/_components/zones/ZoneDashboardSheet.tsx
deleted file mode 100644
index 984a48f..0000000
--- a/apps/frontend/src/app/zone/_components/zones/ZoneDashboardSheet.tsx
+++ /dev/null
@@ -1,421 +0,0 @@
-'use client'
-
-import { useEffect, useMemo, useState } from 'react'
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
- Button,
- Input,
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from 'packages/ui'
-import { getAvatarUrl, socket } from '@openchat/lib'
-import { useStartDirectMessageMutation } from '@/features/chat/mutations'
-import { useCreateChannelMutation } from '@/features/channels/mutations'
-import { useChannels } from '@/features/channels/queries'
-import { useCreateZoneInviteMutation, useRemoveZoneMemberMutation, useUpdateZoneMemberRoleMutation, useUpdateZoneMutation } from '@/features/zones/mutations'
-import { useZoneMembers } from '@/features/zones/queries'
-import { apiClient } from '@/lib/api/client'
-import { useUser } from '@/features/user/queries'
-
-type Friend = {
- id: number
- username: string
- avatar?: string | null
-}
-
-export default function ZoneDashboardSheet({
- zonePublicId,
- zoneName,
- zoneAvatar,
- open,
- onOpenChange,
- onZoneUpdated,
-}: {
- zonePublicId: string
- zoneName: string
- zoneAvatar?: string | null
- open: boolean
- onOpenChange: (open: boolean) => void
- onZoneUpdated?: (zone: { name: string; avatar: string | null }) => void
-}) {
- const { data: currentUser } = useUser()
- const { data: members = [] } = useZoneMembers(zonePublicId, open)
- const { data: channels = [] } = useChannels(zonePublicId, open)
- const updateZoneMutation = useUpdateZoneMutation(zonePublicId)
- const removeMemberMutation = useRemoveZoneMemberMutation(zonePublicId)
- const updateRoleMutation = useUpdateZoneMemberRoleMutation(zonePublicId)
- const createChannelMutation = useCreateChannelMutation(zonePublicId)
- const createInviteMutation = useCreateZoneInviteMutation(zonePublicId)
- const startDirectMessageMutation = useStartDirectMessageMutation()
-
- const [friends, setFriends] = useState
([])
- const [inviteSearch, setInviteSearch] = useState('')
- const [newChannelName, setNewChannelName] = useState('')
- const [newChannelType, setNewChannelType] = useState<'TEXT' | 'VOICE'>('TEXT')
- const [draftZoneName, setDraftZoneName] = useState(zoneName)
- const [draftZoneAvatar, setDraftZoneAvatar] = useState(zoneAvatar ?? null)
- const [avatarFile, setAvatarFile] = useState(null)
- const [sendingInviteFor, setSendingInviteFor] = useState(null)
- const [sentInviteFor, setSentInviteFor] = useState(null)
-
- useEffect(() => {
- setDraftZoneName(zoneName)
- }, [zoneName])
-
- useEffect(() => {
- setDraftZoneAvatar(zoneAvatar ?? null)
- }, [zoneAvatar])
-
- useEffect(() => {
- if (!open) return
-
- let cancelled = false
-
- const loadFriends = async () => {
- try {
- const data = await apiClient.get<{ friends: Friend[] }>('/friends/list')
-
- if (!cancelled) {
- setFriends(data.friends ?? [])
- }
- } catch {
- if (!cancelled) {
- setFriends([])
- }
- }
- }
-
- void loadFriends()
-
- return () => {
- cancelled = true
- }
- }, [open])
-
- const myMembership = useMemo(
- () => members.find((member) => member.id === currentUser?.id) ?? null,
- [currentUser?.id, members],
- )
-
- const manageableFriends = useMemo(() => {
- const memberIds = new Set(members.map((member) => member.id))
- return friends.filter((friend) => !memberIds.has(friend.id))
- }, [friends, members])
-
- const filteredFriends = useMemo(() => {
- const query = inviteSearch.trim().toLowerCase()
- if (!query) return manageableFriends
- return manageableFriends.filter((friend) => friend.username.toLowerCase().includes(query))
- }, [inviteSearch, manageableFriends])
-
- const canManageMembers = myMembership?.role === 'OWNER' || myMembership?.role === 'ADMIN'
- const canManageRoles = myMembership?.role === 'OWNER'
- const canCreateChannels = canManageMembers
- const canManageZoneInfo = canManageMembers
-
- const updateZoneInfo = async () => {
- const nextName = draftZoneName.trim()
- if (!canManageZoneInfo || (!nextName && !avatarFile)) return
-
- const zone = await updateZoneMutation.mutateAsync({
- name: nextName,
- avatar: avatarFile,
- })
-
- setDraftZoneName(zone.name)
- setDraftZoneAvatar(zone.avatar ?? null)
- setAvatarFile(null)
- onZoneUpdated?.({ name: zone.name, avatar: zone.avatar ?? null })
- }
-
- const createInviteLink = async () => {
- const invite = await createInviteMutation.mutateAsync()
- return `${window.location.origin}/zone/invite/${invite.code}`
- }
-
- const copyInviteLink = async (target: 'general') => {
- setSendingInviteFor(target)
-
- try {
- const link = await createInviteLink()
- await navigator.clipboard.writeText(link)
- setSentInviteFor(target)
- window.setTimeout(() => {
- setSentInviteFor((current) => (current === target ? null : current))
- }, 2000)
- } finally {
- setSendingInviteFor(null)
- }
- }
-
- const sendInviteToFriend = async (friend: Friend) => {
- setSendingInviteFor(friend.id)
-
- try {
- const link = await createInviteLink()
- const chatPublicId = await startDirectMessageMutation.mutateAsync(friend.id)
-
- socket.emit('join-room', { chatPublicId })
- socket.emit('private-message', {
- chatPublicId,
- text: `Join my zone: ${link}`,
- })
-
- setSentInviteFor(friend.id)
- window.setTimeout(() => {
- setSentInviteFor((current) => (current === friend.id ? null : current))
- }, 2000)
- } finally {
- setSendingInviteFor(null)
- }
- }
-
- return (
-
-
-
- {zoneName} Dashboard
-
- Manage channels, members, and permissions in real time.
-
-
-
-
- {canManageZoneInfo && (
-
-
-
Zone Profile
-
Rename the zone or upload a new icon.
-
-
-
-
-
-
-
-
-
-
- )}
-
-
-
-
Channels
-
All rooms in this zone update live.
-
-
-
- {channels.map((channel) => (
-
- #{channel.name}
- {channel.type}
-
- ))}
-
-
- {canCreateChannels && (
-
-
Create channel
-
- setNewChannelName(event.target.value)}
- placeholder="new-room"
- className="border-white/10 bg-white/[0.04] text-white"
- />
-
-
-
-
- )}
-
-
-
-
-
Members
-
Change roles or remove members from the zone.
-
-
-
- {members.map((member) => {
- const canRemove =
- canManageMembers &&
- member.id !== currentUser?.id &&
- member.role !== 'OWNER' &&
- !(myMembership?.role === 'ADMIN' && member.role === 'ADMIN')
-
- const canEditRole =
- canManageRoles &&
- member.id !== currentUser?.id &&
- member.role !== 'OWNER'
-
- return (
-
-
-
-
- {member.username[0]?.toUpperCase()}
-
-
-
-
-
{member.username}
-
{member.role}
-
-
- {canEditRole ? (
-
- ) : (
-
- {member.role}
-
- )}
-
- {canRemove && (
-
- )}
-
- )
- })}
-
-
-
- {canManageMembers && (
-
-
-
Invite By Link
-
Nobody gets added directly. They have to open the invite link and join themselves.
-
-
-
-
-
-
General invite link
-
Copy it and send it anywhere in DM.
-
-
-
-
-
setInviteSearch(event.target.value)}
- placeholder="Search friends..."
- className="mb-3 border-white/10 bg-white/[0.04] text-white"
- />
-
-
- {filteredFriends.map((friend) => (
-
-
-
-
- {friend.username[0]?.toUpperCase()}
-
-
-
{friend.username}
-
-
- ))}
-
- {filteredFriends.length === 0 && (
-
- No available friends to add.
-
- )}
-
-
-
- )}
-
-
-
- )
-}
diff --git a/apps/frontend/src/app/zone/_components/zones/ZoneDropdownMenu.tsx b/apps/frontend/src/app/zone/_components/zones/ZoneDropdownMenu.tsx
index b8f3487..c10c537 100644
--- a/apps/frontend/src/app/zone/_components/zones/ZoneDropdownMenu.tsx
+++ b/apps/frontend/src/app/zone/_components/zones/ZoneDropdownMenu.tsx
@@ -17,7 +17,7 @@ import {
useUpdateZoneMutation,
} from '@/features/zones/mutations'
import { useUser } from '@/features/user/queries'
-import { ZoneSettingsModal } from './ZoneSettingsModal'
+import ZoneSettings from './ZoneSettings'
type ZoneDropdownMenuProps = {
zonePublicId: string
@@ -160,7 +160,6 @@ export function ZoneDropdownMenu({
)}
-
{isOwner ? (