11 changed files with 1112 additions and 893 deletions
@ -1,17 +0,0 @@ |
|||||||
import { usePrimaryPage } from '@/contexts/primary-page-context' |
|
||||||
import { MessageCircle } from 'lucide-react' |
|
||||||
import BottomNavigationBarItem from './BottomNavigationBarItem' |
|
||||||
|
|
||||||
export default function DiscussionsButton() { |
|
||||||
const { navigate, current, currentPageProps, display } = usePrimaryPage() |
|
||||||
const spell = (currentPageProps as { spell?: string } | undefined)?.spell |
|
||||||
|
|
||||||
return ( |
|
||||||
<BottomNavigationBarItem |
|
||||||
active={current === 'spells' && display && spell === 'discussions'} |
|
||||||
onClick={() => navigate('spells', { spell: 'discussions' })} |
|
||||||
> |
|
||||||
<MessageCircle /> |
|
||||||
</BottomNavigationBarItem> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,24 +0,0 @@ |
|||||||
import { usePrimaryPage } from '@/contexts/primary-page-context' |
|
||||||
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
|
||||||
import { MessageCircle } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import SidebarItem from './SidebarItem' |
|
||||||
|
|
||||||
export default function DiscussionsButton() { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { navigate, current, currentPageProps, display } = usePrimaryPage() |
|
||||||
const { primaryViewType } = usePrimaryNoteView() |
|
||||||
const spell = (currentPageProps as { spell?: string } | undefined)?.spell |
|
||||||
|
|
||||||
return ( |
|
||||||
<SidebarItem |
|
||||||
title={t('Discussions')} |
|
||||||
onClick={() => navigate('spells', { spell: 'discussions' })} |
|
||||||
active={ |
|
||||||
display && current === 'spells' && primaryViewType === null && spell === 'discussions' |
|
||||||
} |
|
||||||
> |
|
||||||
<MessageCircle strokeWidth={3} /> |
|
||||||
</SidebarItem> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,389 @@ |
|||||||
|
import UserAvatar from '@/components/UserAvatar' |
||||||
|
import Username from '@/components/Username' |
||||||
|
import { Separator } from '@/components/ui/separator' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { formatPubkey } from '@/lib/pubkey' |
||||||
|
import { getSpellName } from '@/services/spell.service' |
||||||
|
import { FAUX_SPELL_ORDER } from '@/constants' |
||||||
|
import { Check, Star } from 'lucide-react' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import type { TFunction } from 'i18next' |
||||||
|
import { |
||||||
|
encodeFollowSetSpellId, |
||||||
|
fauxSpellLabelKey, |
||||||
|
FAUX_SPELL_ICON, |
||||||
|
FOLLOW_SET_SPELL_ROW_ICON, |
||||||
|
getFollowSetDTag, |
||||||
|
labelFollowSetEvent |
||||||
|
} from './fauxSpellConfig' |
||||||
|
|
||||||
|
function spellPickerPrimaryAndSecondary( |
||||||
|
spell: Event, |
||||||
|
accountPubkey: string | undefined, |
||||||
|
labelFor: (e: Event) => string, |
||||||
|
options?: { omitAuthorNpub?: boolean } |
||||||
|
) { |
||||||
|
const primary = labelFor(spell) |
||||||
|
const isOwn = !!(accountPubkey && spell.pubkey === accountPubkey) |
||||||
|
const shortTitle = primary.trim().length < 4 |
||||||
|
const secondaryParts: string[] = [] |
||||||
|
if (!isOwn && !options?.omitAuthorNpub) secondaryParts.push(formatPubkey(spell.pubkey)) |
||||||
|
if (shortTitle) secondaryParts.push(`${spell.id.slice(0, 8)}…`) |
||||||
|
return { |
||||||
|
primary, |
||||||
|
secondary: secondaryParts.length > 0 ? secondaryParts.join(' · ') : null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function groupSpellsByPubkeySorted(spells: Event[]): { pubkey: string; spells: Event[] }[] { |
||||||
|
const map = new Map<string, Event[]>() |
||||||
|
for (const s of spells) { |
||||||
|
const list = map.get(s.pubkey) |
||||||
|
if (list) list.push(s) |
||||||
|
else map.set(s.pubkey, [s]) |
||||||
|
} |
||||||
|
for (const list of map.values()) { |
||||||
|
list.sort((a, b) => |
||||||
|
getSpellName(a).localeCompare(getSpellName(b), undefined, { sensitivity: 'base' }) |
||||||
|
) |
||||||
|
} |
||||||
|
return [...map.entries()] |
||||||
|
.sort(([a], [b]) => a.localeCompare(b)) |
||||||
|
.map(([pk, list]) => ({ pubkey: pk, spells: list })) |
||||||
|
} |
||||||
|
|
||||||
|
function SpellSheetAuthorHeader({ userId }: { userId: string }) { |
||||||
|
return ( |
||||||
|
<div className="flex items-center gap-2 border-b border-border/60 bg-muted/40 px-3 py-2"> |
||||||
|
<UserAvatar userId={userId} size="small" className="shrink-0" /> |
||||||
|
<Username |
||||||
|
userId={userId} |
||||||
|
className="min-w-0 text-sm font-semibold" |
||||||
|
skeletonClassName="h-4 w-28" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function SpellSheetOptionRow({ |
||||||
|
spell, |
||||||
|
selected, |
||||||
|
accountPubkey, |
||||||
|
labelFor, |
||||||
|
onPick, |
||||||
|
groupedUnderAuthor = false, |
||||||
|
starred = false, |
||||||
|
onToggleStar, |
||||||
|
starTitleAdd, |
||||||
|
starTitleRemove, |
||||||
|
t |
||||||
|
}: { |
||||||
|
spell: Event |
||||||
|
selected: boolean |
||||||
|
accountPubkey: string | undefined |
||||||
|
labelFor: (e: Event) => string |
||||||
|
onPick: (e: Event) => void |
||||||
|
groupedUnderAuthor?: boolean |
||||||
|
starred?: boolean |
||||||
|
onToggleStar?: (spell: Event) => void |
||||||
|
starTitleAdd?: string |
||||||
|
starTitleRemove?: string |
||||||
|
t: TFunction |
||||||
|
}) { |
||||||
|
const { primary, secondary } = spellPickerPrimaryAndSecondary(spell, accountPubkey, labelFor, { |
||||||
|
omitAuthorNpub: groupedUnderAuthor |
||||||
|
}) |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'flex w-full items-stretch gap-0.5 rounded-lg transition-colors', |
||||||
|
selected && 'bg-accent/50' |
||||||
|
)} |
||||||
|
> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
role="option" |
||||||
|
aria-selected={selected} |
||||||
|
className={cn( |
||||||
|
'flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors', |
||||||
|
'hover:bg-accent/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', |
||||||
|
'rounded-lg' |
||||||
|
)} |
||||||
|
onClick={() => onPick(spell)} |
||||||
|
> |
||||||
|
<span className="flex size-4 shrink-0 items-center justify-center"> |
||||||
|
{selected ? <Check className="size-4" aria-hidden /> : null} |
||||||
|
</span> |
||||||
|
<div className="flex min-w-0 flex-1 flex-col items-stretch gap-0.5"> |
||||||
|
<span className="truncate text-left font-medium leading-tight">{primary}</span> |
||||||
|
{secondary ? ( |
||||||
|
<span className="truncate text-left text-xs text-muted-foreground">{secondary}</span> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
{onToggleStar ? ( |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className="flex shrink-0 items-center justify-center rounded-lg px-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" |
||||||
|
title={starred ? starTitleRemove ?? t('Remove from favorites') : starTitleAdd ?? t('Add to favorites')} |
||||||
|
aria-label={starred ? starTitleRemove ?? t('Remove from favorites') : starTitleAdd ?? t('Add to favorites')} |
||||||
|
onClick={(e) => { |
||||||
|
e.preventDefault() |
||||||
|
e.stopPropagation() |
||||||
|
onToggleStar(spell) |
||||||
|
}} |
||||||
|
> |
||||||
|
<Star |
||||||
|
className={cn('size-4', starred && 'fill-amber-400 text-amber-500')} |
||||||
|
aria-hidden |
||||||
|
/> |
||||||
|
</button> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export type SpellPickerContentProps = { |
||||||
|
t: TFunction |
||||||
|
pubkey: string | null | undefined |
||||||
|
selectedSpell: Event | null |
||||||
|
selectedFauxSpell: string | null |
||||||
|
favoriteSpellSet: Set<string> |
||||||
|
starredSpellsForPicker: Event[] |
||||||
|
ownSpells: Event[] |
||||||
|
followSpells: Event[] |
||||||
|
otherSpells: Event[] |
||||||
|
followSetListEvents: Event[] |
||||||
|
spellMenuLabel: (spell: Event) => string |
||||||
|
spellStarAddTitle: string |
||||||
|
spellStarRemoveTitle: string |
||||||
|
pickSpell: (spell: Event | null) => void |
||||||
|
pickFauxSpell: (name: string | null) => void |
||||||
|
clearSpellSelection: () => void |
||||||
|
toggleFavoriteSpell: (spell: Event) => void |
||||||
|
} |
||||||
|
|
||||||
|
export function SpellPickerContent({ |
||||||
|
t, |
||||||
|
pubkey, |
||||||
|
selectedSpell, |
||||||
|
selectedFauxSpell, |
||||||
|
favoriteSpellSet, |
||||||
|
starredSpellsForPicker, |
||||||
|
ownSpells, |
||||||
|
followSpells, |
||||||
|
otherSpells, |
||||||
|
followSetListEvents, |
||||||
|
spellMenuLabel, |
||||||
|
spellStarAddTitle, |
||||||
|
spellStarRemoveTitle, |
||||||
|
pickSpell, |
||||||
|
pickFauxSpell, |
||||||
|
clearSpellSelection, |
||||||
|
toggleFavoriteSpell |
||||||
|
}: SpellPickerContentProps) { |
||||||
|
const followSpellGroups = groupSpellsByPubkeySorted(followSpells) |
||||||
|
const otherSpellGroups = groupSpellsByPubkeySorted(otherSpells) |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
{starredSpellsForPicker.length > 0 ? ( |
||||||
|
<> |
||||||
|
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> |
||||||
|
{t('Starred spells')} |
||||||
|
</p> |
||||||
|
{starredSpellsForPicker.map((spell) => ( |
||||||
|
<SpellSheetOptionRow |
||||||
|
key={spell.id} |
||||||
|
spell={spell} |
||||||
|
selected={selectedSpell?.id === spell.id} |
||||||
|
accountPubkey={pubkey ?? undefined} |
||||||
|
labelFor={(e) => getSpellName(e)} |
||||||
|
onPick={pickSpell} |
||||||
|
starred |
||||||
|
t={t} |
||||||
|
onToggleStar={(s) => void toggleFavoriteSpell(s)} |
||||||
|
starTitleAdd={spellStarAddTitle} |
||||||
|
starTitleRemove={spellStarRemoveTitle} |
||||||
|
/> |
||||||
|
))} |
||||||
|
<Separator className="my-2" /> |
||||||
|
</> |
||||||
|
) : null} |
||||||
|
{FAUX_SPELL_ORDER.flatMap((name) => { |
||||||
|
if ( |
||||||
|
(name === 'notifications' || |
||||||
|
name === 'following' || |
||||||
|
name === 'heatMap' || |
||||||
|
name === 'bookmarks' || |
||||||
|
name === 'interests') && |
||||||
|
!pubkey |
||||||
|
) { |
||||||
|
return [] |
||||||
|
} |
||||||
|
const Icon = FAUX_SPELL_ICON[name] |
||||||
|
const selected = selectedFauxSpell === name |
||||||
|
const builtinRow = ( |
||||||
|
<button |
||||||
|
key={name} |
||||||
|
type="button" |
||||||
|
role="option" |
||||||
|
aria-selected={selected} |
||||||
|
className={cn( |
||||||
|
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors', |
||||||
|
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', |
||||||
|
selected && 'bg-accent/50' |
||||||
|
)} |
||||||
|
onClick={() => pickFauxSpell(name)} |
||||||
|
> |
||||||
|
<span className="flex size-4 shrink-0 items-center justify-center"> |
||||||
|
{selected ? <Check className="size-4" aria-hidden /> : null} |
||||||
|
</span> |
||||||
|
<Icon className="size-4 shrink-0" /> |
||||||
|
<span className="min-w-0 flex-1 truncate text-left font-medium"> |
||||||
|
{t(fauxSpellLabelKey(name))} |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
) |
||||||
|
if (name !== 'following' || !pubkey || followSetListEvents.length === 0) { |
||||||
|
return [builtinRow] |
||||||
|
} |
||||||
|
const setRows = followSetListEvents.flatMap((ev) => { |
||||||
|
const d = getFollowSetDTag(ev) |
||||||
|
if (!d) return [] |
||||||
|
const spellId = encodeFollowSetSpellId(d) |
||||||
|
const setSelected = selectedFauxSpell === spellId |
||||||
|
return [ |
||||||
|
<button |
||||||
|
key={spellId} |
||||||
|
type="button" |
||||||
|
role="option" |
||||||
|
aria-selected={setSelected} |
||||||
|
className={cn( |
||||||
|
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 pl-8 text-left text-sm transition-colors', |
||||||
|
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', |
||||||
|
setSelected && 'bg-accent/50' |
||||||
|
)} |
||||||
|
onClick={() => pickFauxSpell(spellId)} |
||||||
|
> |
||||||
|
<span className="flex size-4 shrink-0 items-center justify-center"> |
||||||
|
{setSelected ? <Check className="size-4" aria-hidden /> : null} |
||||||
|
</span> |
||||||
|
<FOLLOW_SET_SPELL_ROW_ICON className="size-4 shrink-0 opacity-80" /> |
||||||
|
<span className="min-w-0 flex-1 truncate text-left font-medium"> |
||||||
|
{labelFollowSetEvent(ev)} |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
] |
||||||
|
}) |
||||||
|
return [builtinRow, ...setRows] |
||||||
|
})} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
role="option" |
||||||
|
aria-selected={!selectedSpell && !selectedFauxSpell} |
||||||
|
className={cn( |
||||||
|
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors', |
||||||
|
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', |
||||||
|
!selectedSpell && !selectedFauxSpell && 'bg-accent/50' |
||||||
|
)} |
||||||
|
onClick={clearSpellSelection} |
||||||
|
> |
||||||
|
<span className="flex size-4 shrink-0 items-center justify-center"> |
||||||
|
{!selectedSpell && !selectedFauxSpell ? <Check className="size-4" aria-hidden /> : null} |
||||||
|
</span> |
||||||
|
<span className="min-w-0 flex-1 truncate text-left font-normal text-muted-foreground"> |
||||||
|
{t('Select a spell…')} |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
|
||||||
|
{ownSpells.length > 0 ? ( |
||||||
|
<> |
||||||
|
<Separator className="my-2" /> |
||||||
|
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> |
||||||
|
{t('spellPickerSectionYours')} |
||||||
|
</p> |
||||||
|
{ownSpells.map((spell) => ( |
||||||
|
<SpellSheetOptionRow |
||||||
|
key={spell.id} |
||||||
|
spell={spell} |
||||||
|
selected={selectedSpell?.id === spell.id} |
||||||
|
accountPubkey={pubkey ?? undefined} |
||||||
|
labelFor={spellMenuLabel} |
||||||
|
onPick={pickSpell} |
||||||
|
t={t} |
||||||
|
starred={favoriteSpellSet.has(spell.id.toLowerCase())} |
||||||
|
onToggleStar={(s) => void toggleFavoriteSpell(s)} |
||||||
|
starTitleAdd={spellStarAddTitle} |
||||||
|
starTitleRemove={spellStarRemoveTitle} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</> |
||||||
|
) : null} |
||||||
|
|
||||||
|
{followSpells.length > 0 ? ( |
||||||
|
<> |
||||||
|
<Separator className="my-2" /> |
||||||
|
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> |
||||||
|
{t('Spells from follows', { count: followSpells.length })} |
||||||
|
</p> |
||||||
|
{followSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( |
||||||
|
<div key={authorPk} className="mt-2 overflow-hidden rounded-lg border border-border/60"> |
||||||
|
<SpellSheetAuthorHeader userId={authorPk} /> |
||||||
|
<div className="px-0.5 py-0.5"> |
||||||
|
{groupSpells.map((spell) => ( |
||||||
|
<SpellSheetOptionRow |
||||||
|
key={spell.id} |
||||||
|
spell={spell} |
||||||
|
selected={selectedSpell?.id === spell.id} |
||||||
|
accountPubkey={pubkey ?? undefined} |
||||||
|
labelFor={spellMenuLabel} |
||||||
|
onPick={pickSpell} |
||||||
|
t={t} |
||||||
|
groupedUnderAuthor |
||||||
|
starred={favoriteSpellSet.has(spell.id.toLowerCase())} |
||||||
|
onToggleStar={(s) => void toggleFavoriteSpell(s)} |
||||||
|
starTitleAdd={spellStarAddTitle} |
||||||
|
starTitleRemove={spellStarRemoveTitle} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</> |
||||||
|
) : null} |
||||||
|
|
||||||
|
{otherSpells.length > 0 ? ( |
||||||
|
<> |
||||||
|
<Separator className="my-2" /> |
||||||
|
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> |
||||||
|
{t('Other spells', { count: otherSpells.length })} |
||||||
|
</p> |
||||||
|
{otherSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( |
||||||
|
<div key={authorPk} className="mt-2 overflow-hidden rounded-lg border border-border/60"> |
||||||
|
<SpellSheetAuthorHeader userId={authorPk} /> |
||||||
|
<div className="px-0.5 py-0.5"> |
||||||
|
{groupSpells.map((spell) => ( |
||||||
|
<SpellSheetOptionRow |
||||||
|
key={spell.id} |
||||||
|
spell={spell} |
||||||
|
selected={selectedSpell?.id === spell.id} |
||||||
|
accountPubkey={pubkey ?? undefined} |
||||||
|
labelFor={spellMenuLabel} |
||||||
|
onPick={pickSpell} |
||||||
|
t={t} |
||||||
|
groupedUnderAuthor |
||||||
|
starred={favoriteSpellSet.has(spell.id.toLowerCase())} |
||||||
|
onToggleStar={(s) => void toggleFavoriteSpell(s)} |
||||||
|
starTitleAdd={spellStarAddTitle} |
||||||
|
starTitleRemove={spellStarRemoveTitle} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</> |
||||||
|
) : null} |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,86 @@ |
|||||||
|
import { |
||||||
|
decodeFollowSetSpellId, |
||||||
|
encodeFollowSetSpellId, |
||||||
|
getFollowSetDTag, |
||||||
|
isFollowSetSpellId, |
||||||
|
labelFollowSetEvent |
||||||
|
} from '@/lib/follow-set-spell' |
||||||
|
import { FAUX_SPELL_ORDER } from '@/constants' |
||||||
|
import { |
||||||
|
Bell, |
||||||
|
Bookmark, |
||||||
|
CalendarDays, |
||||||
|
Flame, |
||||||
|
Gift, |
||||||
|
Hash, |
||||||
|
Image as ImageIcon, |
||||||
|
MessageSquare, |
||||||
|
Users, |
||||||
|
type LucideIcon |
||||||
|
} from 'lucide-react' |
||||||
|
|
||||||
|
export type FauxSpellName = (typeof FAUX_SPELL_ORDER)[number] |
||||||
|
|
||||||
|
export function isBuiltinFauxSpell(s: string): s is FauxSpellName { |
||||||
|
return (FAUX_SPELL_ORDER as readonly string[]).includes(s) |
||||||
|
} |
||||||
|
|
||||||
|
/** URL / picker param: built-in faux name or encoded follow-set spell id. */ |
||||||
|
export function isFauxSpellPageParam(s: string): boolean { |
||||||
|
if (isBuiltinFauxSpell(s)) return true |
||||||
|
if (!isFollowSetSpellId(s)) return false |
||||||
|
return decodeFollowSetSpellId(s) != null |
||||||
|
} |
||||||
|
|
||||||
|
export function isFollowFeedFauxSpellId(s: string | null): boolean { |
||||||
|
return s === 'following' || (!!s && isFollowSetSpellId(s)) |
||||||
|
} |
||||||
|
|
||||||
|
export function fauxSpellLabelKey(name: FauxSpellName): string { |
||||||
|
switch (name) { |
||||||
|
case 'notifications': |
||||||
|
return 'Notifications' |
||||||
|
case 'discussions': |
||||||
|
return 'Discussions' |
||||||
|
case 'following': |
||||||
|
return 'Following' |
||||||
|
case 'heatMap': |
||||||
|
return 'Heat map' |
||||||
|
case 'followPacks': |
||||||
|
return 'Follow Packs' |
||||||
|
case 'media': |
||||||
|
return 'Media' |
||||||
|
case 'interests': |
||||||
|
return 'Interests' |
||||||
|
case 'bookmarks': |
||||||
|
return 'Bookmarks' |
||||||
|
case 'calendar': |
||||||
|
return 'Calendar' |
||||||
|
default: |
||||||
|
return 'Spells' |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const FAUX_SPELL_ICON: Record<FauxSpellName, LucideIcon> = { |
||||||
|
notifications: Bell, |
||||||
|
discussions: MessageSquare, |
||||||
|
following: Users, |
||||||
|
heatMap: Flame, |
||||||
|
followPacks: Gift, |
||||||
|
media: ImageIcon, |
||||||
|
interests: Hash, |
||||||
|
bookmarks: Bookmark, |
||||||
|
calendar: CalendarDays |
||||||
|
} |
||||||
|
|
||||||
|
/** Lucide icon for a follow-set row (indented under Following). */ |
||||||
|
export const FOLLOW_SET_SPELL_ROW_ICON = Users |
||||||
|
|
||||||
|
/** Follow-set rows + URL segments */ |
||||||
|
export { |
||||||
|
decodeFollowSetSpellId, |
||||||
|
encodeFollowSetSpellId, |
||||||
|
getFollowSetDTag, |
||||||
|
isFollowSetSpellId, |
||||||
|
labelFollowSetEvent |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,453 @@ |
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import { kinds as nostrKinds } from 'nostr-tools' |
||||||
|
import { ExtendedKind, DEFAULT_FEED_SHOW_KINDS } from '@/constants' |
||||||
|
import { getPubkeysFromPTags } from '@/lib/tag' |
||||||
|
import { normalizeUrl } from '@/lib/url' |
||||||
|
import { |
||||||
|
augmentSubRequestsWithFavoritesFastReadAndInbox, |
||||||
|
getRelayUrlsWithFavoritesFastReadAndInbox, |
||||||
|
userReadRelaysWithHttp |
||||||
|
} from '@/lib/favorites-feed-relays' |
||||||
|
import { |
||||||
|
computeKind777SpellFeedSubscriptionKey, |
||||||
|
computeSpellSubRequestsIdentityKey |
||||||
|
} from '@/lib/spell-feed-request-identity' |
||||||
|
import { isUserInEventMentions } from '@/lib/event' |
||||||
|
import { |
||||||
|
decodeFollowSetSpellId, |
||||||
|
getFollowSetDTag, |
||||||
|
isFollowSetSpellId, |
||||||
|
pubkeysFromFollowSetEvent |
||||||
|
} from '@/lib/follow-set-spell' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { |
||||||
|
buildBookmarksSubRequests, |
||||||
|
buildCalendarSpellFilter, |
||||||
|
buildDiscussionFilter, |
||||||
|
buildInterestsSubRequests, |
||||||
|
buildMediaSpellFilter, |
||||||
|
buildNotificationsSpellSubRequests, |
||||||
|
buildWebBookmarksSpellSubRequests, |
||||||
|
NOTIFICATION_SPELL_LOADING_SAFETY_MS, |
||||||
|
FAUX_SPELL_EVENT_LIMIT, |
||||||
|
MEDIA_SPELL_KINDS, |
||||||
|
NOTIFICATION_SPELL_KINDS, |
||||||
|
applyFauxSpellCapsToSubRequests |
||||||
|
} from './fauxSpellFeeds' |
||||||
|
import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service' |
||||||
|
import type { TFeedSubRequest } from '@/types' |
||||||
|
import { isFollowFeedFauxSpellId } from './fauxSpellConfig' |
||||||
|
import storage from '@/services/local-storage.service' |
||||||
|
|
||||||
|
function useNoteListHideReplies() { |
||||||
|
const [hideReplies, setHideReplies] = useState(() => storage.getNoteListMode() === 'posts') |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const sync = () => setHideReplies(storage.getNoteListMode() === 'posts') |
||||||
|
window.addEventListener('noteListModeChanged', sync) |
||||||
|
return () => window.removeEventListener('noteListModeChanged', sync) |
||||||
|
}, []) |
||||||
|
|
||||||
|
return hideReplies |
||||||
|
} |
||||||
|
|
||||||
|
export type UseSpellsPageFeedArgs = { |
||||||
|
selectedFauxSpell: string | null |
||||||
|
selectedSpell: Event | null |
||||||
|
pubkey: string | null | undefined |
||||||
|
relayList: { read: string[]; write: string[] } | null | undefined |
||||||
|
favoriteRelays: string[] |
||||||
|
blockedRelays: string[] |
||||||
|
notificationsFeedPubkey: string | null |
||||||
|
interestListEvent: Event | null | undefined |
||||||
|
bookmarkListEvent: Event | null | undefined |
||||||
|
followListEvent: Event | null | undefined |
||||||
|
contacts: string[] |
||||||
|
contactsSyncKey: string |
||||||
|
followSetListEvents: Event[] |
||||||
|
followSetCatalogLoading: boolean |
||||||
|
kindFilterShowKinds: number[] |
||||||
|
} |
||||||
|
|
||||||
|
export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { |
||||||
|
const { |
||||||
|
selectedFauxSpell, |
||||||
|
selectedSpell, |
||||||
|
pubkey, |
||||||
|
relayList, |
||||||
|
favoriteRelays, |
||||||
|
blockedRelays, |
||||||
|
notificationsFeedPubkey, |
||||||
|
interestListEvent, |
||||||
|
bookmarkListEvent, |
||||||
|
followListEvent, |
||||||
|
contacts, |
||||||
|
contactsSyncKey, |
||||||
|
followSetListEvents, |
||||||
|
followSetCatalogLoading, |
||||||
|
kindFilterShowKinds |
||||||
|
} = a |
||||||
|
|
||||||
|
const hideRepliesFollowing = useNoteListHideReplies() |
||||||
|
const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([]) |
||||||
|
|
||||||
|
const normalizedReadSorted = relayList |
||||||
|
? [...relayList.read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort() |
||||||
|
: [] |
||||||
|
const normalizedWriteSorted = relayList |
||||||
|
? [...relayList.write].map((u) => normalizeUrl(u) || u).filter(Boolean).sort() |
||||||
|
: [] |
||||||
|
|
||||||
|
const relayMailboxStableKey = |
||||||
|
relayList == null |
||||||
|
? '' |
||||||
|
: JSON.stringify({ r: normalizedReadSorted, w: normalizedWriteSorted }) |
||||||
|
|
||||||
|
const relayListWriteKey = useMemo(() => { |
||||||
|
if (!relayList) return '[]' |
||||||
|
return JSON.stringify(normalizedWriteSorted) |
||||||
|
}, [relayMailboxStableKey]) |
||||||
|
|
||||||
|
const sortedFavoriteRelaysKey = useMemo( |
||||||
|
() => |
||||||
|
JSON.stringify( |
||||||
|
[...favoriteRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((x, y) => x.localeCompare(y)) |
||||||
|
), |
||||||
|
[favoriteRelays] |
||||||
|
) |
||||||
|
const sortedBlockedRelaysKey = useMemo( |
||||||
|
() => |
||||||
|
JSON.stringify( |
||||||
|
[...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((x, y) => x.localeCompare(y)) |
||||||
|
), |
||||||
|
[blockedRelays] |
||||||
|
) |
||||||
|
|
||||||
|
const followSetListStableKey = useMemo( |
||||||
|
() => |
||||||
|
followSetListEvents |
||||||
|
.map((e) => { |
||||||
|
const d = getFollowSetDTag(e) ?? '' |
||||||
|
return `${d}:${e.id}:${e.created_at}` |
||||||
|
}) |
||||||
|
.sort() |
||||||
|
.join('|'), |
||||||
|
[followSetListEvents] |
||||||
|
) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!pubkey || !isFollowFeedFauxSpellId(selectedFauxSpell)) { |
||||||
|
setFollowingSubRequests([]) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const followSetD = |
||||||
|
selectedFauxSpell && isFollowSetSpellId(selectedFauxSpell) |
||||||
|
? decodeFollowSetSpellId(selectedFauxSpell) |
||||||
|
: null |
||||||
|
|
||||||
|
if (followSetD && followSetCatalogLoading) { |
||||||
|
setFollowingSubRequests([]) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
let cancelled = false |
||||||
|
void (async () => { |
||||||
|
const augment = (raw: TFeedSubRequest[]) => |
||||||
|
augmentSubRequestsWithFavoritesFastReadAndInbox( |
||||||
|
raw, |
||||||
|
favoriteRelays, |
||||||
|
blockedRelays, |
||||||
|
userReadRelaysWithHttp(relayList), |
||||||
|
{ userWriteRelays: relayList?.write ?? [] } |
||||||
|
) |
||||||
|
try { |
||||||
|
if (selectedFauxSpell === 'following') { |
||||||
|
const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] |
||||||
|
const provisionalAuthors = [...new Set([pubkey, ...fromTags])] |
||||||
|
let provisionalOk = false |
||||||
|
try { |
||||||
|
const rawProv = await client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey) |
||||||
|
if (!cancelled) { |
||||||
|
setFollowingSubRequests(augment(rawProv)) |
||||||
|
provisionalOk = true |
||||||
|
} |
||||||
|
} catch { |
||||||
|
/* refined wave may still succeed */ |
||||||
|
} |
||||||
|
|
||||||
|
let followings = fromTags |
||||||
|
try { |
||||||
|
followings = await client.fetchFollowings(pubkey) |
||||||
|
} catch { |
||||||
|
followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] |
||||||
|
} |
||||||
|
const fullAuthors = [...new Set([pubkey, ...followings])] |
||||||
|
const sameSet = |
||||||
|
fullAuthors.length === provisionalAuthors.length && |
||||||
|
fullAuthors.every((p) => provisionalAuthors.includes(p)) && |
||||||
|
provisionalAuthors.every((p) => fullAuthors.includes(p)) |
||||||
|
if (sameSet) { |
||||||
|
if (!provisionalOk && !cancelled) { |
||||||
|
try { |
||||||
|
const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey) |
||||||
|
if (!cancelled) setFollowingSubRequests(augment(req)) |
||||||
|
} catch { |
||||||
|
if (!cancelled) setFollowingSubRequests([]) |
||||||
|
} |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey) |
||||||
|
if (!cancelled) setFollowingSubRequests(augment(req)) |
||||||
|
} else if (followSetD) { |
||||||
|
const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD) |
||||||
|
if (!ev) { |
||||||
|
if (!cancelled) setFollowingSubRequests([]) |
||||||
|
return |
||||||
|
} |
||||||
|
const listed = pubkeysFromFollowSetEvent(ev) |
||||||
|
const authorPubkeys = [pubkey, ...listed] |
||||||
|
const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) |
||||||
|
if (!cancelled) setFollowingSubRequests(augment(req)) |
||||||
|
} else { |
||||||
|
if (!cancelled) setFollowingSubRequests([]) |
||||||
|
} |
||||||
|
} catch { |
||||||
|
if (!cancelled) setFollowingSubRequests([]) |
||||||
|
} |
||||||
|
})() |
||||||
|
return () => { |
||||||
|
cancelled = true |
||||||
|
} |
||||||
|
}, [ |
||||||
|
selectedFauxSpell, |
||||||
|
pubkey, |
||||||
|
sortedFavoriteRelaysKey, |
||||||
|
sortedBlockedRelaysKey, |
||||||
|
relayMailboxStableKey, |
||||||
|
followSetCatalogLoading, |
||||||
|
followSetListStableKey, |
||||||
|
followListEvent?.id, |
||||||
|
favoriteRelays, |
||||||
|
blockedRelays, |
||||||
|
relayList, |
||||||
|
followListEvent |
||||||
|
]) |
||||||
|
|
||||||
|
const interestTagsStableKey = interestListEvent |
||||||
|
? JSON.stringify( |
||||||
|
[...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) |
||||||
|
) |
||||||
|
: '' |
||||||
|
const bookmarkTagsStableKey = bookmarkListEvent |
||||||
|
? JSON.stringify( |
||||||
|
[...bookmarkListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) |
||||||
|
) |
||||||
|
: '' |
||||||
|
|
||||||
|
const fauxFeedRelaysDepsKey = [ |
||||||
|
sortedFavoriteRelaysKey, |
||||||
|
sortedBlockedRelaysKey, |
||||||
|
interestListEvent?.id ?? '', |
||||||
|
String(interestListEvent?.created_at ?? ''), |
||||||
|
interestTagsStableKey, |
||||||
|
bookmarkListEvent?.id ?? '', |
||||||
|
String(bookmarkListEvent?.created_at ?? ''), |
||||||
|
bookmarkTagsStableKey |
||||||
|
].join('\0') |
||||||
|
|
||||||
|
const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => { |
||||||
|
if ( |
||||||
|
!selectedFauxSpell || |
||||||
|
isFollowFeedFauxSpellId(selectedFauxSpell) || |
||||||
|
selectedFauxSpell === 'heatMap' |
||||||
|
) |
||||||
|
return [] |
||||||
|
const fauxSpellSkipSocialKindBlocked = |
||||||
|
selectedFauxSpell === 'calendar' || |
||||||
|
selectedFauxSpell === 'followPacks' || |
||||||
|
selectedFauxSpell === 'media' || |
||||||
|
selectedFauxSpell === 'bookmarks' || |
||||||
|
selectedFauxSpell === 'interests' |
||||||
|
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( |
||||||
|
favoriteRelays, |
||||||
|
blockedRelays, |
||||||
|
userReadRelaysWithHttp(relayList), |
||||||
|
{ |
||||||
|
userWriteRelays: relayList?.write ?? [], |
||||||
|
applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
if (selectedFauxSpell === 'notifications') { |
||||||
|
if (!notificationsFeedPubkey || !feedUrls.length) return [] |
||||||
|
return buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey) |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'discussions') { |
||||||
|
if (!feedUrls.length) return [] |
||||||
|
return [{ urls: feedUrls, filter: buildDiscussionFilter() }] |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'media') { |
||||||
|
if (!feedUrls.length) return [] |
||||||
|
return [{ urls: feedUrls, filter: buildMediaSpellFilter() }] |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'calendar') { |
||||||
|
if (!feedUrls.length) return [] |
||||||
|
return [{ urls: feedUrls, filter: buildCalendarSpellFilter() }] |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'interests') { |
||||||
|
if (!pubkey || !interestListEvent) return [] |
||||||
|
const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) |
||||||
|
return buildInterestsSubRequests(feedUrls, topics, DEFAULT_FEED_SHOW_KINDS) |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'bookmarks') { |
||||||
|
if (!pubkey) return [] |
||||||
|
const idReqs = buildBookmarksSubRequests(bookmarkListEvent ?? null, feedUrls) |
||||||
|
const webReqs = buildWebBookmarksSpellSubRequests(pubkey, feedUrls) |
||||||
|
return [...idReqs, ...webReqs] |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'followPacks') { |
||||||
|
if (!feedUrls.length) return [] |
||||||
|
return [ |
||||||
|
{ |
||||||
|
urls: feedUrls, |
||||||
|
filter: { kinds: [ExtendedKind.FOLLOW_PACK], limit: FAUX_SPELL_EVENT_LIMIT } |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
|
return [] |
||||||
|
}, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey, interestListEvent, bookmarkListEvent, favoriteRelays, blockedRelays, relayList]) |
||||||
|
|
||||||
|
const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => { |
||||||
|
const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '') |
||||||
|
? followingSubRequests |
||||||
|
: syncFauxSubRequests |
||||||
|
return applyFauxSpellCapsToSubRequests(base) |
||||||
|
}, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests]) |
||||||
|
|
||||||
|
const spellSubRequests = useMemo<TFeedSubRequest[]>(() => { |
||||||
|
if (!selectedSpell) return [] |
||||||
|
const relayListWrite = relayList?.write ?? [] |
||||||
|
const ctx = { pubkey: pubkey ?? null, contacts } |
||||||
|
const filter = spellEventToFilter(selectedSpell, ctx) |
||||||
|
if (!filter) return [] |
||||||
|
const relays = getRelaysForSpell(selectedSpell, { relayListWrite }) |
||||||
|
if (!relays.length) return [] |
||||||
|
return [{ urls: relays, filter }] |
||||||
|
}, [selectedSpell, pubkey, contactsSyncKey, relayListWriteKey, contacts, relayList]) |
||||||
|
|
||||||
|
const subRequests = useMemo<TFeedSubRequest[]>(() => { |
||||||
|
if (selectedFauxSpell) return fauxSubRequests |
||||||
|
return spellSubRequests |
||||||
|
}, [selectedFauxSpell, fauxSubRequests, spellSubRequests]) |
||||||
|
|
||||||
|
const spellFeedSubscriptionKey = useMemo(() => { |
||||||
|
if (selectedFauxSpell) return computeSpellSubRequestsIdentityKey(subRequests) |
||||||
|
if (selectedSpell) return computeKind777SpellFeedSubscriptionKey(selectedSpell, subRequests) |
||||||
|
return '' |
||||||
|
}, [selectedFauxSpell, selectedSpell, subRequests]) |
||||||
|
|
||||||
|
const spellBrowseRelayUrls = useMemo(() => { |
||||||
|
const set = new Set<string>() |
||||||
|
for (const req of subRequests) { |
||||||
|
for (const u of req.urls) { |
||||||
|
const n = normalizeUrl(u) || u |
||||||
|
if (n) set.add(n) |
||||||
|
} |
||||||
|
} |
||||||
|
return [...set].sort() |
||||||
|
}, [subRequests]) |
||||||
|
|
||||||
|
const spellBrowseRelayUrlsKey = spellBrowseRelayUrls.join('|') |
||||||
|
|
||||||
|
const showKindsTagKey = useMemo(() => { |
||||||
|
if (!selectedSpell) return '' |
||||||
|
return selectedSpell.tags |
||||||
|
.filter((tag) => tag[0] === 'k') |
||||||
|
.map((tag) => tag[1]) |
||||||
|
.sort() |
||||||
|
.join(',') |
||||||
|
}, [selectedSpell?.id]) |
||||||
|
|
||||||
|
const followingShowKindsKey = |
||||||
|
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell) |
||||||
|
? JSON.stringify(kindFilterShowKinds) |
||||||
|
: '' |
||||||
|
|
||||||
|
const showKinds = useMemo(() => { |
||||||
|
if (selectedFauxSpell === 'notifications') { |
||||||
|
return [...NOTIFICATION_SPELL_KINDS] |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'discussions') { |
||||||
|
return [ExtendedKind.DISCUSSION] |
||||||
|
} |
||||||
|
if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) { |
||||||
|
const k = kindFilterShowKinds |
||||||
|
const out = [...k] |
||||||
|
if (!out.includes(nostrKinds.Repost)) out.push(nostrKinds.Repost) |
||||||
|
if (!out.includes(ExtendedKind.GENERIC_REPOST)) out.push(ExtendedKind.GENERIC_REPOST) |
||||||
|
return out.sort((x, y) => x - y) |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'followPacks') { |
||||||
|
return [ExtendedKind.FOLLOW_PACK] |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'media') { |
||||||
|
return [...MEDIA_SPELL_KINDS] |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'calendar') { |
||||||
|
return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME] |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'interests') { |
||||||
|
return [...DEFAULT_FEED_SHOW_KINDS] |
||||||
|
} |
||||||
|
if (selectedFauxSpell === 'bookmarks') { |
||||||
|
const out = [...DEFAULT_FEED_SHOW_KINDS] |
||||||
|
if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK) |
||||||
|
return out.sort((a, b) => a - b) |
||||||
|
} |
||||||
|
if (!selectedSpell) return [1] |
||||||
|
const kinds = selectedSpell.tags |
||||||
|
.filter((tag) => tag[0] === 'k') |
||||||
|
.map((tag) => parseInt(tag[1], 10)) |
||||||
|
.filter((n) => !Number.isNaN(n)) |
||||||
|
return kinds.length ? kinds : [1] |
||||||
|
}, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey]) |
||||||
|
|
||||||
|
const fauxNoteListUseFilterAsIs = useMemo(() => { |
||||||
|
if (!selectedFauxSpell) return true |
||||||
|
if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) return false |
||||||
|
return true |
||||||
|
}, [selectedFauxSpell]) |
||||||
|
|
||||||
|
const spellFauxMergeTimeline = useMemo( |
||||||
|
() => !!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell), |
||||||
|
[selectedFauxSpell] |
||||||
|
) |
||||||
|
|
||||||
|
const notificationsMentionExtraHide = useCallback( |
||||||
|
(evt: Event) => |
||||||
|
notificationsFeedPubkey ? !isUserInEventMentions(evt, notificationsFeedPubkey) : false, |
||||||
|
[notificationsFeedPubkey] |
||||||
|
) |
||||||
|
|
||||||
|
return { |
||||||
|
relayMailboxStableKey, |
||||||
|
sortedFavoriteRelaysKey, |
||||||
|
sortedBlockedRelaysKey, |
||||||
|
followingSubRequests, |
||||||
|
fauxSubRequests, |
||||||
|
subRequests, |
||||||
|
spellFeedSubscriptionKey, |
||||||
|
spellBrowseRelayUrls, |
||||||
|
spellBrowseRelayUrlsKey, |
||||||
|
showKinds, |
||||||
|
fauxNoteListUseFilterAsIs, |
||||||
|
spellFauxMergeTimeline, |
||||||
|
notificationsMentionExtraHide, |
||||||
|
hideRepliesFollowing, |
||||||
|
NOTIFICATION_SPELL_LOADING_SAFETY_MS, |
||||||
|
NOTIFICATION_SPELL_KINDS |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue