Files
MathApp_frontend/src/screens/NotificationsScreen.tsx
AliReza 0e56d9c8aa math in Persian text: lists read right-to-left, every content text goes through MathText, chat input picks its direction per line
Measured by rendering every question, option and several tutor answers in
Chrome and comparing the on-screen x of each number (2026-09-29):
- A list inside a sentence ('از −۳ به −۲، −۱، ۰ می‌رسیم', 'الگویِ ۲، ۵، ۸')
  was locked as one left-to-right run, so all 482 read backwards. '،' and
  '؛' outside brackets now split the run; sets and coordinates in brackets
  stay one run. Locked runs are isolates (isolate-override), otherwise the
  comma between two of them was still pulled left-to-right.
- Without MathText 704 of 931 minus signs and 602 of 983 expressions were
  misplaced; lesson and island titles, the lesson intro, unit blurb,
  notification bodies and ticket messages now go through it.
- The chat box takes its direction from each line's first letter, so a
  line of pure math stays left-to-right while typing. A line that starts
  in Persian can still show its math reordered inside the box; a textarea
  cannot isolate part of its text. The sent message renders correctly.

Four expressions with Persian words inside them ('f(x) = (ریشه دوم x) + ۵۰')
remain ambiguous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:46:18 +03:30

101 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Bell, Check, Crown, FileText, MessageCircle, Star, Trophy, Users, type LucideIcon } from 'lucide-react';
import { api, type NotificationItem } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
import { relativeTime, toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
xp_earned: { icon: Star, color: '#E8A400', bg: '#FFF8E1' },
lesson_completed: { icon: Check, color: '#5FBF8F', bg: '#EAF8F0' },
node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' },
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' },
family_link_accepted: { icon: Users, color: '#FF7A3D', bg: '#FFF1E6' },
weekly_report_ready: { icon: FileText, color: '#91ADC6', bg: '#EAF2F6' },
};
const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' };
export function NotificationsScreen() {
const { setUnreadCount } = useAppData();
const toast = useToast();
const list = useAsync(async () => {
const r = await api.notifications.list();
setUnreadCount(r.unreadCount);
return r.items;
}, []);
const items = list.data ?? [];
const unread = items.filter((n) => !n.isRead).length;
const markLocal = (predicate: (n: NotificationItem) => boolean) => {
list.setData((prev) => prev?.map((n) => (predicate(n) ? { ...n, isRead: true } : n)));
};
async function markOne(n: NotificationItem) {
if (n.isRead) return;
markLocal((x) => x.id === n.id);
setUnreadCount(Math.max(0, unread - 1));
api.notifications.markRead(n.id).catch(() => {});
}
async function markAll() {
markLocal(() => true);
setUnreadCount(0);
try {
await api.notifications.markAllRead();
} catch {
toast('ثبت نشد؛ دوباره امتحان کن', 'error');
list.reload();
}
}
return (
<Screen>
<ScreenHeader title="اعلان‌ها" color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'idle', size: 54 }}>
{unread > 0 && (
<button type="button" onClick={() => void markAll()} className="mt-3 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold">
همه خوانده شد
</button>
)}
</ScreenHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">
{list.loading ? (
<LoadingState text="دارم اعلان‌ها رو میارم…" />
) : list.error ? (
<ErrorState error={list.error} onRetry={list.reload} />
) : items.length === 0 ? (
<EmptyState text="فعلاً خبری نیست" />
) : (
<ul className="flex flex-col gap-2">
{items.map((n) => {
const s = styleOf(n.type);
const Icon = s.icon;
return (
<li key={n.id}>
<button
type="button"
onClick={() => void markOne(n)}
className={`flex w-full items-start gap-3 rounded-2xl p-3.5 text-right ${n.isRead ? 'border border-line bg-white' : 'border border-indigo/20 bg-[#EEF1FF]'}`}
>
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full" style={{ backgroundColor: s.bg }}>
<Icon size={18} style={{ color: s.color }} />
</span>
<span className="min-w-0 flex-1">
<span className={`block text-sm ${n.isRead ? 'text-muted' : 'font-bold text-ink'}`}>{n.title}</span>
{n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body"><MathText text={toFa(n.body)} /></span>}
<span className="mt-1 block text-[10px] text-faint">{relativeTime(n.createdAt)}</span>
</span>
{!n.isRead && <span className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-coral" />}
</button>
</li>
);
})}
</ul>
)}
</div>
</Screen>
);
}