index.tsx raw

   1  import { Button } from '@/components/ui/button'
   2  import { Checkbox } from '@/components/ui/checkbox'
   3  import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
   4  import { Label } from '@/components/ui/label'
   5  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
   6  import { Separator } from '@/components/ui/separator'
   7  import SocialGraphFilter from '@/components/SocialGraphFilter'
   8  import { ExtendedKind } from '@/constants'
   9  import { cn } from '@/lib/utils'
  10  import { useKindFilter } from '@/providers/KindFilterProvider'
  11  import { useScreenSize } from '@/providers/ScreenSizeProvider'
  12  import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
  13  import { ListFilter } from 'lucide-react'
  14  import { kinds } from 'nostr-tools'
  15  import { useEffect, useMemo, useState } from 'react'
  16  import { useTranslation } from 'react-i18next'
  17  
  18  const KIND_FILTER_OPTIONS = [
  19    { kindGroup: [kinds.ShortTextNote, ExtendedKind.COMMENT], label: 'Posts' },
  20    { kindGroup: [kinds.Repost, kinds.GenericRepost], label: 'Reposts' },
  21    { kindGroup: [kinds.LongFormArticle], label: 'Articles' },
  22    { kindGroup: [kinds.Highlights], label: 'Highlights' },
  23    { kindGroup: [ExtendedKind.POLL], label: 'Polls' },
  24    { kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },
  25    { kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' },
  26    {
  27      kindGroup: [
  28        ExtendedKind.VIDEO,
  29        ExtendedKind.SHORT_VIDEO,
  30        ExtendedKind.ADDRESSABLE_NORMAL_VIDEO,
  31        ExtendedKind.ADDRESSABLE_SHORT_VIDEO
  32      ],
  33      label: 'Video Posts'
  34    }
  35  ]
  36  const ALL_KINDS = KIND_FILTER_OPTIONS.flatMap(({ kindGroup }) => kindGroup)
  37  
  38  export default function KindFilter({
  39    showKinds,
  40    onShowKindsChange,
  41    showSocialGraphFilter = false
  42  }: {
  43    showKinds: number[]
  44    onShowKindsChange: (kinds: number[]) => void
  45    showSocialGraphFilter?: boolean
  46  }) {
  47    const { t } = useTranslation()
  48    const { isSmallScreen } = useScreenSize()
  49    const { showKinds: savedShowKinds } = useKindFilter()
  50    const {
  51      proximityLevel: savedProximity,
  52      includeMode: savedIncludeMode,
  53      updateProximityLevel,
  54      updateIncludeMode
  55    } = useSocialGraphFilter()
  56    const [open, setOpen] = useState(false)
  57    const { updateShowKinds } = useKindFilter()
  58    const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
  59    const [temporaryProximity, setTemporaryProximity] = useState<number | null>(savedProximity)
  60    const [temporaryIncludeMode, setTemporaryIncludeMode] = useState(savedIncludeMode)
  61    const [isPersistent, setIsPersistent] = useState(false)
  62  
  63    const isDifferentFromSaved = useMemo(() => {
  64      const kindsDifferent = !isSameKindFilter(showKinds, savedShowKinds)
  65      const proximityDifferent = showSocialGraphFilter && savedProximity !== null
  66      return kindsDifferent || proximityDifferent
  67    }, [showKinds, savedShowKinds, savedProximity, showSocialGraphFilter])
  68  
  69    const isTemporaryDifferentFromSaved = useMemo(
  70      () => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
  71      [temporaryShowKinds, savedShowKinds]
  72    )
  73  
  74    useEffect(() => {
  75      setTemporaryShowKinds(showKinds)
  76      setTemporaryProximity(savedProximity)
  77      setTemporaryIncludeMode(savedIncludeMode)
  78      setIsPersistent(false)
  79    }, [open, savedProximity, savedIncludeMode])
  80  
  81    const handleApply = () => {
  82      if (temporaryShowKinds.length === 0) {
  83        // must select at least one kind
  84        return
  85      }
  86  
  87      const newShowKinds = [...temporaryShowKinds].sort()
  88      if (!isSameKindFilter(newShowKinds, showKinds)) {
  89        onShowKindsChange(newShowKinds)
  90      }
  91  
  92      // Apply social graph filter changes
  93      if (showSocialGraphFilter) {
  94        if (temporaryProximity !== savedProximity) {
  95          updateProximityLevel(temporaryProximity)
  96        }
  97        if (temporaryIncludeMode !== savedIncludeMode) {
  98          updateIncludeMode(temporaryIncludeMode)
  99        }
 100      }
 101  
 102      if (isPersistent) {
 103        updateShowKinds(newShowKinds)
 104      }
 105  
 106      setIsPersistent(false)
 107      setOpen(false)
 108    }
 109  
 110    const trigger = (
 111      <Button
 112        variant="ghost"
 113        size="titlebar-icon"
 114        className={cn(
 115          'relative w-fit px-3 hover:text-foreground',
 116          !isDifferentFromSaved && 'text-muted-foreground'
 117        )}
 118        onClick={() => {
 119          if (isSmallScreen) {
 120            setOpen(true)
 121          }
 122        }}
 123      >
 124        <ListFilter size={16} />
 125        {isDifferentFromSaved && (
 126          <div className="absolute size-2 rounded-full bg-primary left-7 top-2 ring-2 ring-background" />
 127        )}
 128      </Button>
 129    )
 130  
 131    const content = (
 132      <div>
 133        <div className="grid grid-cols-2 gap-2">
 134          {KIND_FILTER_OPTIONS.map(({ kindGroup, label }) => {
 135            const checked = kindGroup.every((k) => temporaryShowKinds.includes(k))
 136            return (
 137              <div
 138                key={label}
 139                className={cn(
 140                  'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3',
 141                  checked ? 'border-primary/60 bg-primary/5' : 'clickable'
 142                )}
 143                onClick={() => {
 144                  if (!checked) {
 145                    // add all kinds in this group
 146                    setTemporaryShowKinds((prev) => Array.from(new Set([...prev, ...kindGroup])))
 147                  } else {
 148                    // remove all kinds in this group
 149                    setTemporaryShowKinds((prev) => prev.filter((k) => !kindGroup.includes(k)))
 150                  }
 151                }}
 152              >
 153                <p className="leading-none font-medium">{t(label)}</p>
 154                <p className="text-muted-foreground text-xs">kind {kindGroup.join(', ')}</p>
 155              </div>
 156            )
 157          })}
 158        </div>
 159  
 160        <div className="grid grid-cols-3 gap-2 mt-4">
 161          <Button
 162            variant="secondary"
 163            onClick={() => {
 164              setTemporaryShowKinds(ALL_KINDS)
 165            }}
 166          >
 167            {t('Select All')}
 168          </Button>
 169          <Button
 170            variant="secondary"
 171            onClick={() => {
 172              setTemporaryShowKinds([])
 173            }}
 174          >
 175            {t('Clear All')}
 176          </Button>
 177          <Button
 178            variant="secondary"
 179            onClick={() => setTemporaryShowKinds(savedShowKinds)}
 180            disabled={!isTemporaryDifferentFromSaved}
 181          >
 182            {t('Reset')}
 183          </Button>
 184        </div>
 185  
 186        {showSocialGraphFilter && (
 187          <>
 188            <Separator className="my-4" />
 189            <SocialGraphFilter
 190              temporaryProximity={temporaryProximity}
 191              temporaryIncludeMode={temporaryIncludeMode}
 192              onTemporaryProximityChange={setTemporaryProximity}
 193              onTemporaryIncludeModeChange={setTemporaryIncludeMode}
 194            />
 195          </>
 196        )}
 197  
 198        <Label className="flex items-center gap-2 cursor-pointer mt-4">
 199          <Checkbox
 200            id="persistent-filter"
 201            checked={isPersistent}
 202            onCheckedChange={(checked) => setIsPersistent(!!checked)}
 203          />
 204          <span className="text-sm">{t('Set as default filter')}</span>
 205        </Label>
 206  
 207        <Button
 208          onClick={handleApply}
 209          className="mt-4 w-full"
 210          disabled={temporaryShowKinds.length === 0}
 211        >
 212          {t('Apply')}
 213        </Button>
 214      </div>
 215    )
 216  
 217    if (isSmallScreen) {
 218      return (
 219        <>
 220          {trigger}
 221          <Drawer open={open} onOpenChange={setOpen}>
 222            <DrawerTrigger asChild></DrawerTrigger>
 223            <DrawerContent className="px-4">
 224              <DrawerHeader />
 225              {content}
 226            </DrawerContent>
 227          </Drawer>
 228        </>
 229      )
 230    }
 231  
 232    return (
 233      <Popover open={open} onOpenChange={setOpen}>
 234        <PopoverTrigger asChild>{trigger}</PopoverTrigger>
 235        <PopoverContent className="w-96" collisionPadding={16} sideOffset={0}>
 236          {content}
 237        </PopoverContent>
 238      </Popover>
 239    )
 240  }
 241  
 242  function isSameKindFilter(a: number[], b: number[]) {
 243    if (a.length !== b.length) {
 244      return false
 245    }
 246    const sortedA = [...a].sort()
 247    const sortedB = [...b].sort()
 248    return sortedA.every((value, index) => value === sortedB[index])
 249  }
 250