diff --git a/src/app/page.tsx b/src/app/page.tsx index 5c0b696..e7e9be0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,6 +10,7 @@ import { RoomScreen } from "@/components/screens/RoomScreen"; import { MatchmakingScreen } from "@/components/screens/MatchmakingScreen"; import { LeaderboardScreen } from "@/components/screens/LeaderboardScreen"; import { ShopScreen } from "@/components/screens/ShopScreen"; +import { ChatScreen } from "@/components/screens/ChatScreen"; import { AuthScreen } from "@/components/screens/AuthScreen"; import { DailyRewardModal } from "@/components/online/DailyRewardModal"; import { useSessionStore } from "@/lib/session-store"; @@ -53,6 +54,8 @@ function renderScreen(screen: string) { return ; case "shop": return ; + case "chat": + return ; default: return ; } diff --git a/src/components/screens/ChatScreen.tsx b/src/components/screens/ChatScreen.tsx new file mode 100644 index 0000000..f799ba3 --- /dev/null +++ b/src/components/screens/ChatScreen.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { ChevronLeft, ChevronRight, Send } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useOnlineStore } from "@/lib/online-store"; +import { useUIStore } from "@/lib/ui-store"; +import { useI18n } from "@/lib/i18n"; +import { avatarEmoji } from "@/lib/online/types"; +import { cn } from "@/lib/cn"; + +export function ChatScreen() { + const { t, locale } = useI18n(); + const friend = useOnlineStore((s) => s.activeChatFriend); + const messages = useOnlineStore((s) => s.chatMessages); + const sendChat = useOnlineStore((s) => s.sendChat); + const closeChat = useOnlineStore((s) => s.closeChat); + const go = useUIStore((s) => s.go); + const [text, setText] = useState(""); + const endRef = useRef(null); + const Chevron = locale === "fa" ? ChevronRight : ChevronLeft; + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + if (!friend) { + return null; + } + + const back = () => { + closeChat(); + go("friends"); + }; + + const send = async () => { + const v = text.trim(); + if (!v) return; + setText(""); + await sendChat(v); + }; + + return ( +
+ {/* header */} +
+ + {avatarEmoji(friend.avatar)} +
+
{friend.displayName}
+
+ {friend.status === "online" + ? t("friends.online") + : friend.status === "in-game" + ? t("friends.inGame") + : t("friends.offline")} +
+
+
+ + {/* messages */} +
+ {messages.length === 0 && ( +

{t("chat.empty")}

+ )} + {messages.map((m) => ( +
+ {m.text} +
+ ))} +
+
+ + {/* input */} +
+ setText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && send()} + placeholder={t("chat.placeholder")} + className="flex-1 rounded-full bg-navy-900/70 gold-border px-4 py-2.5 text-cream placeholder:text-cream/30 outline-none focus:ring-2 focus:ring-gold-500/40" + /> + +
+
+ ); +} diff --git a/src/components/screens/FriendsScreen.tsx b/src/components/screens/FriendsScreen.tsx index 6900777..79167f3 100644 --- a/src/components/screens/FriendsScreen.tsx +++ b/src/components/screens/FriendsScreen.tsx @@ -1,9 +1,10 @@ "use client"; -import { Check, UserPlus, X } from "lucide-react"; +import { Check, MessageCircle, UserPlus, X } from "lucide-react"; import { useEffect, useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { useOnlineStore } from "@/lib/online-store"; +import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { Friend, PresenceStatus, avatarEmoji } from "@/lib/online/types"; import { cn } from "@/lib/cn"; @@ -23,6 +24,8 @@ export function FriendsScreen() { const accept = useOnlineStore((s) => s.acceptRequest); const decline = useOnlineStore((s) => s.declineRequest); const remove = useOnlineStore((s) => s.removeFriend); + const openChat = useOnlineStore((s) => s.openChat); + const go = useUIStore((s) => s.go); const [query, setQuery] = useState(""); @@ -110,6 +113,16 @@ export function FriendsScreen() { {Math.round(f.rating)} + ))} diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index f6f7d1f..020bd70 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -87,6 +87,13 @@ const fa: Dict = { "common.soon": "به‌زودی", "common.copy": "کپی", "common.copied": "کپی شد", + "common.free": "رایگان", + + "chat.title": "گفتگو", + "chat.placeholder": "پیام بنویسید…", + "chat.send": "ارسال", + "chat.empty": "گفتگو را شروع کنید", + "friends.message": "پیام", "profile.title": "پروفایل", "profile.stats": "آمار", @@ -128,7 +135,7 @@ const fa: Dict = { "room.empty": "خالی", "room.waiting": "در انتظار…", "room.start": "شروع بازی", - "room.stake": "شرط", + "room.stake": "سکه ورودی", "room.leave": "ترک اتاق", "room.pickFriend": "یک دوست را انتخاب کنید", @@ -265,6 +272,13 @@ const en: Dict = { "common.soon": "Coming soon", "common.copy": "Copy", "common.copied": "Copied", + "common.free": "Free", + + "chat.title": "Chat", + "chat.placeholder": "Type a message…", + "chat.send": "Send", + "chat.empty": "Start the conversation", + "friends.message": "Message", "profile.title": "Profile", "profile.stats": "Stats", @@ -306,7 +320,7 @@ const en: Dict = { "room.empty": "Empty", "room.waiting": "Waiting…", "room.start": "Start game", - "room.stake": "Stake", + "room.stake": "Entry coins", "room.leave": "Leave room", "room.pickFriend": "Pick a friend", diff --git a/src/lib/online-store.ts b/src/lib/online-store.ts index c22fbde..e47f9e2 100644 --- a/src/lib/online-store.ts +++ b/src/lib/online-store.ts @@ -3,6 +3,7 @@ import { create } from "zustand"; import { CreateRoomOptions, MatchmakingOptions, getService } from "./online/service"; import { + ChatMessage, Friend, FriendRequest, LeaderboardEntry, @@ -35,11 +36,19 @@ interface OnlineStore { cancelMatchmaking: () => Promise; loadLeaderboard: () => Promise; + + // chat + activeChatFriend: Friend | null; + chatMessages: ChatMessage[]; + openChat: (friend: Friend) => Promise; + sendChat: (text: string) => Promise; + closeChat: () => void; } let roomUnsub: (() => void) | null = null; let mmUnsub: (() => void) | null = null; let friendUnsub: (() => void) | null = null; +let chatUnsub: (() => void) | null = null; export const useOnlineStore = create((set, get) => ({ friends: [], @@ -128,4 +137,36 @@ export const useOnlineStore = create((set, get) => ({ const leaderboard = await getService().getLeaderboard(); set({ leaderboard }); }, + + activeChatFriend: null, + chatMessages: [], + + openChat: async (friend) => { + const svc = getService(); + set({ activeChatFriend: friend, chatMessages: await svc.getMessages(friend.id) }); + await svc.markRead(friend.id); + if (chatUnsub) chatUnsub(); + chatUnsub = svc.onChat((friendId, msgs) => { + const active = get().activeChatFriend; + if (active && active.id === friendId) { + set({ chatMessages: msgs }); + svc.markRead(friendId); + } + }); + }, + + sendChat: async (text) => { + const friend = get().activeChatFriend; + if (!friend || !text.trim()) return; + await getService().sendMessage(friend.id, text); + set({ chatMessages: await getService().getMessages(friend.id) }); + }, + + closeChat: () => { + if (chatUnsub) { + chatUnsub(); + chatUnsub = null; + } + set({ activeChatFriend: null, chatMessages: [] }); + }, })); diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 9d4654d..29ad613 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -12,6 +12,8 @@ import { import { AVATARS, AuthSession, + ChatMessage, + Conversation, DailyRewardState, Friend, FriendRequest, @@ -52,8 +54,22 @@ const LS = { session: "hokm.session", profile: "hokm.profile", daily: "hokm.daily", + chats: "hokm.chats", }; +const CANNED_REPLIES = [ + "سلام! 👋", + "بزن بریم 🔥", + "یه دست دیگه؟", + "من آماده‌ام", + "آفرین، کارت خوب بود", + "حکم چی بکنیم؟", + "😂😂", + "الان میام بازی", + "حتماً!", + "تو رو خدا این دفعه کُتمون نکن 😅", +]; + function load(key: string): T | null { if (!isBrowser()) return null; try { @@ -132,14 +148,19 @@ export class MockOnlineService implements OnlineService { private currentOppRating = 1000; private lastOtp = ""; + private messages: Record = {}; + private unread: Record = {}; + private roomCbs = new Set<(r: Room) => void>(); private mmCbs = new Set<(s: MatchmakingState) => void>(); private friendCbs = new Set<(f: Friend[]) => void>(); + private chatCbs = new Set<(friendId: string, m: ChatMessage[]) => void>(); private timers: ReturnType[] = []; constructor() { this.session = load(LS.session); this.profile = load(LS.profile); + this.messages = load>(LS.chats) ?? {}; this.seedFriends(); } @@ -320,6 +341,73 @@ export class MockOnlineService implements OnlineService { return () => this.friendCbs.delete(cb); } + /* ------------------------------- chat ------------------------------ */ + + private saveChats() { + save(LS.chats, this.messages); + } + private emitChat(friendId: string) { + const msgs = this.messages[friendId] ?? []; + for (const cb of this.chatCbs) cb(friendId, [...msgs]); + } + + async listConversations(): Promise { + const convs: Conversation[] = []; + for (const friend of this.friends) { + const msgs = this.messages[friend.id]; + if (!msgs || msgs.length === 0) continue; + convs.push({ + friend, + lastMessage: msgs[msgs.length - 1], + unread: this.unread[friend.id] ?? 0, + }); + } + return convs.sort( + (a, b) => (b.lastMessage?.ts ?? 0) - (a.lastMessage?.ts ?? 0) + ); + } + + async getMessages(friendId: string): Promise { + return [...(this.messages[friendId] ?? [])]; + } + + async sendMessage(friendId: string, text: string): Promise { + const msg: ChatMessage = { + id: rid("m"), + fromMe: true, + text: text.trim(), + ts: Date.now(), + }; + this.messages[friendId] = [...(this.messages[friendId] ?? []), msg]; + this.saveChats(); + this.emitChat(friendId); + + // simulate a reply from the friend + this.after(randInt(900, 1900), () => { + const reply: ChatMessage = { + id: rid("m"), + fromMe: false, + text: pick(CANNED_REPLIES), + ts: Date.now(), + }; + this.messages[friendId] = [...(this.messages[friendId] ?? []), reply]; + this.unread[friendId] = (this.unread[friendId] ?? 0) + 1; + this.saveChats(); + this.emitChat(friendId); + }); + + return msg; + } + + async markRead(friendId: string) { + this.unread[friendId] = 0; + } + + onChat(cb: (friendId: string, m: ChatMessage[]) => void): Unsubscribe { + this.chatCbs.add(cb); + return () => this.chatCbs.delete(cb); + } + /* ------------------------------ rooms ------------------------------ */ private seatYou(): RoomSeat { diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index 978377d..e17cb6f 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -4,6 +4,8 @@ import { AuthSession, + ChatMessage, + Conversation, DailyRewardState, Friend, FriendRequest, @@ -53,6 +55,13 @@ export interface OnlineService { removeFriend(id: string): Promise; onFriends(cb: (friends: Friend[]) => void): Unsubscribe; + /* ----- chat ----- */ + listConversations(): Promise; + getMessages(friendId: string): Promise; + sendMessage(friendId: string, text: string): Promise; + markRead(friendId: string): Promise; + onChat(cb: (friendId: string, messages: ChatMessage[]) => void): Unsubscribe; + /* ----- rooms ----- */ createRoom(opts: CreateRoomOptions): Promise; setPartner(roomId: string, friendId: string | null): Promise; diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index d0c3f60..f5cf239 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -245,6 +245,21 @@ export interface DailyRewardState { available: boolean; } +/* ------------------------------- Chat -------------------------------- */ + +export interface ChatMessage { + id: string; + fromMe: boolean; + text: string; + ts: number; +} + +export interface Conversation { + friend: Friend; + lastMessage: ChatMessage | null; + unread: number; +} + /* ------------------------------ Avatars ------------------------------ */ export const AVATARS: { id: string; emoji: string }[] = [ diff --git a/src/lib/ui-store.ts b/src/lib/ui-store.ts index 3fc4a60..01e6efe 100644 --- a/src/lib/ui-store.ts +++ b/src/lib/ui-store.ts @@ -12,6 +12,7 @@ export type Screen = | "matchmaking" | "leaderboard" | "shop" + | "chat" | "game"; // the table (used for both ai + online) interface UIStore {