feat: implemented WOT

This commit is contained in:
daniyal 2024-11-11 22:37:49 +05:00
parent bc782c775a
commit 0aac63d968
20 changed files with 686 additions and 126 deletions

View File

@ -7,6 +7,9 @@ VITE_ADMIN_NPUBS= <A comma separated list of npubs>
# A dedicated npub used for reporting mods, blogs, profile and etc. # A dedicated npub used for reporting mods, blogs, profile and etc.
VITE_REPORTING_NPUB= <npub1...> VITE_REPORTING_NPUB= <npub1...>
# A dedicated npub used for site WOT.
VITE_SITE_WOT_NPUB= <npub1...>
# if there's no featured image, or if the image breaks somewhere down the line, then it should default to this image # if there's no featured image, or if the image breaks somewhere down the line, then it should default to this image
VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png

View File

@ -25,6 +25,7 @@ jobs:
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_SITE_WOT_NPUB"=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
cat .env cat .env

View File

@ -25,6 +25,7 @@ jobs:
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_SITE_WOT_NPUB"=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
cat .env cat .env

View File

@ -32,6 +32,7 @@ jobs:
run: | run: |
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB"=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env

View File

@ -1,7 +1,13 @@
import { useAppSelector } from 'hooks' import { useAppSelector } from 'hooks'
import React from 'react' import React from 'react'
import { Dispatch, SetStateAction } from 'react' import { Dispatch, SetStateAction } from 'react'
import { FilterOptions, ModeratedFilter, NSFWFilter, SortBy } from 'types' import {
FilterOptions,
ModeratedFilter,
NSFWFilter,
SortBy,
WOTFilterOptions
} from 'types'
type Props = { type Props = {
filterOptions: FilterOptions filterOptions: FilterOptions
@ -15,6 +21,7 @@ export const ModFilter = React.memo(
return ( return (
<div className='IBMSecMain'> <div className='IBMSecMain'>
<div className='FiltersMain'> <div className='FiltersMain'>
{/* sort filter options */}
<div className='FiltersMainElement'> <div className='FiltersMainElement'>
<div className='dropdown dropdownMain'> <div className='dropdown dropdownMain'>
<button <button
@ -44,6 +51,8 @@ export const ModFilter = React.memo(
</div> </div>
</div> </div>
</div> </div>
{/* moderation filter options */}
<div className='FiltersMainElement'> <div className='FiltersMainElement'>
<div className='dropdown dropdownMain'> <div className='dropdown dropdownMain'>
<button <button
@ -87,6 +96,63 @@ export const ModFilter = React.memo(
</div> </div>
</div> </div>
</div> </div>
{/* wot filter options */}
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.wot}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(WOTFilterOptions).map((item, index) => {
// when user is not logged in
if (
(item === WOTFilterOptions.Site_And_Mine ||
item === WOTFilterOptions.Mine_Only) &&
!userState.auth
) {
return null
}
// when logged in user not admin
if (item === WOTFilterOptions.None) {
const isAdmin =
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
const isOwnProfile =
filterOptions.author &&
userState.auth &&
userState.user?.pubkey === filterOptions.author
if (!(isAdmin || isOwnProfile)) return null
}
return (
<div
key={`wotFilterOption-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
wot: item
}))
}
>
{item}
</div>
)
})}
</div>
</div>
</div>
{/* nsfw filter options */}
<div className='FiltersMainElement'> <div className='FiltersMainElement'>
<div className='dropdown dropdownMain'> <div className='dropdown dropdownMain'>
<button <button
@ -115,6 +181,8 @@ export const ModFilter = React.memo(
</div> </div>
</div> </div>
</div> </div>
{/* source filter options */}
<div className='FiltersMainElement'> <div className='FiltersMainElement'>
<div className='dropdown dropdownMain'> <div className='dropdown dropdownMain'>
<button <button

View File

@ -365,7 +365,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
if (!event.sig) throw new Error('Before publishing first sign the event!') if (!event.sig) throw new Error('Before publishing first sign the event!')
return event return event
.publish(undefined, 30000) .publish(undefined, 10000)
.then((res) => { .then((res) => {
const relaysPublishedOn = Array.from(res) const relaysPublishedOn = Array.from(res)
return relaysPublishedOn.map((relay) => relay.url) return relaysPublishedOn.map((relay) => relay.url)

View File

@ -6,9 +6,11 @@ import {
ModeratedFilter, ModeratedFilter,
MuteLists, MuteLists,
NSFWFilter, NSFWFilter,
SortBy SortBy,
WOTFilterOptions
} from 'types' } from 'types'
import { npubToHex } from 'utils' import { npubToHex } from 'utils'
import { useAppSelector } from './redux'
export const useFilteredMods = ( export const useFilteredMods = (
mods: ModDetails[], mods: ModDetails[],
@ -20,6 +22,8 @@ export const useFilteredMods = (
user: MuteLists user: MuteLists
} }
) => { ) => {
const { siteWot, userWot } = useAppSelector((state) => state.wot)
return useMemo(() => { return useMemo(() => {
const nsfwFilter = (mods: ModDetails[]) => { const nsfwFilter = (mods: ModDetails[]) => {
// Determine the filtering logic based on the NSFW filter option // Determine the filtering logic based on the NSFW filter option
@ -36,8 +40,27 @@ export const useFilteredMods = (
} }
} }
const wotFilter = (mods: ModDetails[]) => {
// Determine the filtering logic based on the WOT filter option
switch (filterOptions.wot) {
case WOTFilterOptions.None:
return mods
case WOTFilterOptions.Site_Only:
return mods.filter((mod) => siteWot.includes(mod.author))
case WOTFilterOptions.Mine_Only:
return mods.filter((mod) => userWot.includes(mod.author))
case WOTFilterOptions.Site_And_Mine:
return mods.filter(
(mod) =>
siteWot.includes(mod.author) || userWot.includes(mod.author)
)
}
}
let filtered = nsfwFilter(mods) let filtered = nsfwFilter(mods)
filtered = wotFilter(filtered)
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner = const isOwner =
userState.user?.npub && userState.user?.npub &&
@ -74,10 +97,13 @@ export const useFilteredMods = (
userState.user?.npub, userState.user?.npub,
filterOptions.sort, filterOptions.sort,
filterOptions.moderated, filterOptions.moderated,
filterOptions.wot,
filterOptions.nsfw, filterOptions.nsfw,
filterOptions.author, filterOptions.author,
mods, mods,
muteLists, muteLists,
nsfwList nsfwList,
siteWot,
userWot
]) ])
} }

View File

@ -21,6 +21,7 @@ import '../styles/popup.css'
import { npubToHex } from '../utils' import { npubToHex } from '../utils'
import logo from '../assets/img/DEG Mods Logo With Text.svg' import logo from '../assets/img/DEG Mods Logo With Text.svg'
import placeholder from '../assets/img/DEG Mods Default PP.png' import placeholder from '../assets/img/DEG Mods Default PP.png'
import { setUserWot } from 'store/reducers/wot'
export const Header = () => { export const Header = () => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@ -48,6 +49,7 @@ export const Header = () => {
if (opts.type === 'logout') { if (opts.type === 'logout') {
dispatch(setAuth(null)) dispatch(setAuth(null))
dispatch(setUser(null)) dispatch(setUser(null))
dispatch(setUserWot([]))
} else { } else {
dispatch( dispatch(
setAuth({ setAuth({

View File

@ -3,13 +3,123 @@ import { Footer } from './footer'
import { Header } from './header' import { Header } from './header'
import { SocialNav } from './socialNav' import { SocialNav } from './socialNav'
import { Head } from './head' import { Head } from './head'
import { useAppDispatch, useAppSelector, useNDKContext } from 'hooks'
import { useEffect } from 'react'
import { npubToHex } from 'utils'
import { calculateWot } from 'utils/wot'
import {
setSiteWot,
setSiteWotLevel,
setSiteWotStatus,
setUserWot,
setUserWotLevel,
WOTStatus
} from 'store/reducers/wot'
import { toast } from 'react-toastify'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { NDKKind } from '@nostr-dev-kit/ndk'
import { UserRelaysType } from 'types'
export const Layout = () => { export const Layout = () => {
const dispatch = useAppDispatch()
const { ndk, fetchEventFromUserRelays } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const { siteWotStatus, siteWotLevel, userWotLevel } = useAppSelector(
(state) => state.wot
)
// calculate site's wot
useEffect(() => {
if (ndk) {
const SITE_WOT_NPUB = import.meta.env.VITE_SITE_WOT_NPUB
const hexPubkey = npubToHex(SITE_WOT_NPUB)
if (hexPubkey) {
dispatch(setSiteWotStatus(WOTStatus.LOADING))
calculateWot(hexPubkey, ndk, siteWotLevel)
.then((wot) => {
dispatch(setSiteWot(Array.from(wot)))
dispatch(setSiteWotStatus(WOTStatus.LOADED))
})
.catch((err) => {
console.trace('An error occurred in calculating site WOT', err)
toast.error('An error occurred in calculating site web-of-trust!')
dispatch(setSiteWotStatus(WOTStatus.FAILED))
})
}
}
}, [ndk, siteWotLevel, dispatch])
// calculate user's wot
useEffect(() => {
if (ndk && userState.user?.pubkey) {
const hexPubkey = npubToHex(userState.user.pubkey as string)
if (hexPubkey)
calculateWot(hexPubkey, ndk, userWotLevel)
.then((wot) => {
dispatch(setUserWot(Array.from(wot)))
})
.catch((err) => {
console.trace('An error occurred in calculating user WOT', err)
toast.error('An error occurred in calculating user web-of-trust!')
})
}
}, [ndk, userState.user, userWotLevel, dispatch])
// get site's wot level
useEffect(() => {
const SITE_WOT_NPUB = import.meta.env.VITE_SITE_WOT_NPUB
const hexPubkey = npubToHex(SITE_WOT_NPUB)
if (hexPubkey) {
fetchEventFromUserRelays(
{
kinds: [NDKKind.AppSpecificData],
'#d': ['degmods']
},
hexPubkey,
UserRelaysType.Both
).then((event) => {
if (event) {
const wot = event.tagValue('wot')
if (wot) dispatch(setSiteWotLevel(parseInt(wot)))
}
})
}
}, [dispatch, fetchEventFromUserRelays])
// get user's wot level
useEffect(() => {
if (userState.user?.pubkey) {
const hexPubkey = npubToHex(userState.user.pubkey as string)
if (hexPubkey) {
fetchEventFromUserRelays(
{
kinds: [NDKKind.AppSpecificData],
'#d': ['degmods']
},
hexPubkey,
UserRelaysType.Both
).then((event) => {
if (event) {
const wot = event.tagValue('wot')
if (wot) dispatch(setUserWotLevel(parseInt(wot)))
}
})
}
}
}, [userState.user, dispatch, fetchEventFromUserRelays])
return ( return (
<> <>
<Head /> <Head />
<Header /> <Header />
<Outlet /> {siteWotStatus === WOTStatus.LOADED && <Outlet />}
{siteWotStatus === WOTStatus.LOADING && (
<LoadingSpinner desc="Loading site's web-of-trust" />
)}
{siteWotStatus === WOTStatus.FAILED && (
<h3>Failed to load site's web-of-trust</h3>
)}
<Footer /> <Footer />
<SocialNav /> <SocialNav />
</> </>

View File

@ -21,7 +21,8 @@ import {
ModDetails, ModDetails,
ModeratedFilter, ModeratedFilter,
NSFWFilter, NSFWFilter,
SortBy SortBy,
WOTFilterOptions
} from 'types' } from 'types'
import { extractModData, isModDataComplete, scrollIntoView } from 'utils' import { extractModData, isModDataComplete, scrollIntoView } from 'utils'
@ -37,7 +38,8 @@ export const GamePage = () => {
sort: SortBy.Latest, sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW, nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host, source: window.location.host,
moderated: ModeratedFilter.Moderated moderated: ModeratedFilter.Moderated,
wot: WOTFilterOptions.Site_Only
}) })
const [mods, setMods] = useState<ModDetails[]>([]) const [mods, setMods] = useState<ModDetails[]>([])

View File

@ -8,6 +8,7 @@ import { GameCard } from '../components/GameCard'
import { ModCard } from '../components/ModCard' import { ModCard } from '../components/ModCard'
import { LANDING_PAGE_DATA } from '../constants' import { LANDING_PAGE_DATA } from '../constants'
import { import {
useAppSelector,
useDidMount, useDidMount,
useGames, useGames,
useMuteLists, useMuteLists,
@ -258,6 +259,7 @@ const DisplayMod = ({ naddr }: DisplayModProps) => {
const DisplayLatestMods = () => { const DisplayLatestMods = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { fetchMods } = useNDKContext() const { fetchMods } = useNDKContext()
const { siteWot, userWot } = useAppSelector((state) => state.wot)
const [isFetchingLatestMods, setIsFetchingLatestMods] = useState(true) const [isFetchingLatestMods, setIsFetchingLatestMods] = useState(true)
const [latestMods, setLatestMods] = useState<ModDetails[]>([]) const [latestMods, setLatestMods] = useState<ModDetails[]>([])
@ -267,7 +269,10 @@ const DisplayLatestMods = () => {
useDidMount(() => { useDidMount(() => {
fetchMods({ source: window.location.host }) fetchMods({ source: window.location.host })
.then((mods) => { .then((mods) => {
setLatestMods(mods) const wotFilteredMods = mods.filter(
(mod) => siteWot.includes(mod.author) || userWot.includes(mod.author)
)
setLatestMods(wotFilteredMods)
}) })
.finally(() => { .finally(() => {
setIsFetchingLatestMods(false) setIsFetchingLatestMods(false)

View File

@ -22,7 +22,8 @@ import {
ModDetails, ModDetails,
ModeratedFilter, ModeratedFilter,
NSFWFilter, NSFWFilter,
SortBy SortBy,
WOTFilterOptions
} from '../types' } from '../types'
import { scrollIntoView } from 'utils' import { scrollIntoView } from 'utils'
@ -35,7 +36,8 @@ export const ModsPage = () => {
sort: SortBy.Latest, sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW, nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host, source: window.location.host,
moderated: ModeratedFilter.Moderated moderated: ModeratedFilter.Moderated,
wot: WOTFilterOptions.Site_Only
}) })
const muteLists = useMuteLists() const muteLists = useMuteLists()
const nsfwList = useNSFWList() const nsfwList = useNSFWList()

View File

@ -26,7 +26,8 @@ import {
NSFWFilter, NSFWFilter,
SortBy, SortBy,
UserProfile, UserProfile,
UserRelaysType UserRelaysType,
WOTFilterOptions
} from 'types' } from 'types'
import { import {
copyTextToClipboard, copyTextToClipboard,
@ -244,6 +245,7 @@ export const ProfilePage = () => {
nsfw: NSFWFilter.Hide_NSFW, nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host, source: window.location.host,
moderated: ModeratedFilter.Moderated, moderated: ModeratedFilter.Moderated,
wot: WOTFilterOptions.Site_Only,
author: profilePubkey author: profilePubkey
}) })
const muteLists = useMuteLists() const muteLists = useMuteLists()

View File

@ -41,7 +41,8 @@ import {
ModeratedFilter, ModeratedFilter,
MuteLists, MuteLists,
NSFWFilter, NSFWFilter,
SortBy SortBy,
WOTFilterOptions
} from 'types' } from 'types'
import { import {
extractModData, extractModData,
@ -73,7 +74,8 @@ export const SearchPage = () => {
sort: SortBy.Latest, sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW, nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host, source: window.location.host,
moderated: ModeratedFilter.Moderated moderated: ModeratedFilter.Moderated,
wot: WOTFilterOptions.Site_Only
}) })
const [searchTerm, setSearchTerm] = useState( const [searchTerm, setSearchTerm] = useState(

View File

@ -1,6 +1,93 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { useAppDispatch, useAppSelector, useNDKContext } from 'hooks'
import { kinds, UnsignedEvent, Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { toast } from 'react-toastify'
import { setUserWotLevel } from 'store/reducers/wot'
import { UserRelaysType } from 'types'
import { log, LogType, now } from 'utils'
// todo: use components from Input.tsx // todo: use components from Input.tsx
export const PreferencesSetting = () => { export const PreferencesSetting = () => {
const dispatch = useAppDispatch()
const [wotLevel, setWotLevel] = useState(3)
const [isSaving, setIsSaving] = useState(false)
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
const user = useAppSelector((state) => state.user.user)
useEffect(() => {
if (user?.pubkey) {
fetchEventFromUserRelays(
{
kinds: [NDKKind.AppSpecificData],
'#d': ['degmods']
},
user.pubkey as string,
UserRelaysType.Both
).then((event) => {
if (event) {
console.log('event :>> ', event)
const wot = event.tagValue('wot')
if (wot) setWotLevel(parseInt(wot))
}
})
}
}, [user, fetchEventFromUserRelays])
const handleSave = async () => {
setIsSaving(true)
let hexPubkey: string
if (user?.pubkey) {
hexPubkey = user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
}
if (!hexPubkey) {
toast.error('Could not get pubkey')
setIsSaving(false)
return
}
const unsignedEvent: UnsignedEvent = {
kind: kinds.Application,
created_at: now(),
pubkey: hexPubkey,
content: '',
tags: [
['d', 'degmods'],
['wot', wotLevel.toString()]
]
}
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
.then((event) => event as Event)
.catch((err) => {
toast.error('Failed to sign the event!')
log(true, LogType.Error, 'Failed to sign the event!', err)
return null
})
if (!signedEvent) {
setIsSaving(false)
return
}
const ndkEvent = new NDKEvent(ndk, signedEvent)
await publish(ndkEvent)
dispatch(setUserWotLevel(wotLevel))
setIsSaving(false)
}
return ( return (
<>
{isSaving && <LoadingSpinner desc='Saving preferences to relays' />}
<div className='IBMSMSplitMainFullSideFWMid'> <div className='IBMSMSplitMainFullSideFWMid'>
<div className='IBMSMSplitMainFullSideSec'> <div className='IBMSMSplitMainFullSideSec'>
<div className='IBMSMSMBS_Write'> <div className='IBMSMSMBS_Write'>
@ -93,12 +180,13 @@ export const PreferencesSetting = () => {
type='range' type='range'
max='100' max='100'
min='0' min='0'
value='10' value={wotLevel}
onChange={(e) => setWotLevel(parseInt(e.target.value))}
step='1' step='1'
required required
name='WoTLevel' name='WoTLevel'
/> />
<p className='ZapSplitUserBoxRangeText'>10</p> <p className='ZapSplitUserBoxRangeText'>{wotLevel}</p>
</div> </div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'> <div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
<label className='form-label labelMain'> <label className='form-label labelMain'>
@ -113,12 +201,17 @@ export const PreferencesSetting = () => {
</div> </div>
</div> </div>
<div className='IBMSMSMBS_WriteAction'> <div className='IBMSMSMBS_WriteAction'>
<button className='btn btnMain' type='button'> <button
className='btn btnMain'
type='button'
onClick={handleSave}
>
Save Save
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</>
) )
} }

View File

@ -1,9 +1,11 @@
import { configureStore } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit'
import userReducer from './reducers/user' import userReducer from './reducers/user'
import wotReducer from './reducers/wot'
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
user: userReducer user: userReducer,
wot: wotReducer
} }
}) })

84
src/store/reducers/wot.ts Normal file
View File

@ -0,0 +1,84 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export enum WOTStatus {
IDLE, // Not started
LOADING, // Currently loading
LOADED, // Successfully loaded
FAILED // Failed to load
}
export interface IWOT {
siteWot: string[]
siteWotStatus: WOTStatus
siteWotLevel: number
userWot: string[]
userWotStatus: WOTStatus
userWotLevel: number
}
const initialState: IWOT = {
siteWot: [],
siteWotStatus: WOTStatus.IDLE,
siteWotLevel: 3,
userWot: [],
userWotStatus: WOTStatus.IDLE,
userWotLevel: 3
}
export const wotSlice = createSlice({
name: 'wot',
initialState,
reducers: {
setSiteWot(state, action: PayloadAction<string[]>) {
state = {
...state,
siteWot: action.payload,
siteWotStatus: WOTStatus.LOADED
}
return state
},
setUserWot(state, action: PayloadAction<string[]>) {
state = {
...state,
userWot: action.payload,
userWotStatus: WOTStatus.LOADED
}
return state
},
setSiteWotStatus(state, action: PayloadAction<WOTStatus>) {
return {
...state,
siteWotStatus: action.payload
}
},
setUserWotStatus(state, action: PayloadAction<WOTStatus>) {
return {
...state,
userWotStatus: action.payload
}
},
setSiteWotLevel(state, action: PayloadAction<number>) {
return {
...state,
siteWotLevel: action.payload
}
},
setUserWotLevel(state, action: PayloadAction<number>) {
return {
...state,
userWotLevel: action.payload
}
}
}
})
export const {
setSiteWot,
setUserWot,
setSiteWotStatus,
setUserWotStatus,
setSiteWotLevel,
setUserWotLevel
} = wotSlice.actions
export default wotSlice.reducer

View File

@ -17,10 +17,18 @@ export enum ModeratedFilter {
Unmoderated_Fully = 'Unmoderated Fully' Unmoderated_Fully = 'Unmoderated Fully'
} }
export enum WOTFilterOptions {
Site_And_Mine = 'Site & Mine',
Site_Only = 'Site Only',
Mine_Only = 'Mine Only',
None = 'None'
}
export interface FilterOptions { export interface FilterOptions {
sort: SortBy sort: SortBy
nsfw: NSFWFilter nsfw: NSFWFilter
source: string source: string
moderated: ModeratedFilter moderated: ModeratedFilter
wot: WOTFilterOptions
author?: string author?: string
} }

147
src/utils/wot.ts Normal file
View File

@ -0,0 +1,147 @@
import NDK, {
Hexpubkey,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage,
NDKTag
} from '@nostr-dev-kit/ndk'
import { nip19 } from 'nostr-tools'
interface UserRelations {
follows: Set<Hexpubkey>
muted: Set<Hexpubkey>
}
type Network = Map<Hexpubkey, UserRelations>
export const calculateWot = async (
pubkey: Hexpubkey,
ndk: NDK,
targetWOTScore: number
) => {
const network: Network = new Map()
const WOT = new Set<Hexpubkey>()
const userRelations = await findFollowsAndMuteUsers(pubkey, ndk)
network.set(pubkey, userRelations)
// find the userRelations of every user in follow list
const follows = Array.from(userRelations.follows)
const promises = follows.map((user) =>
findFollowsAndMuteUsers(user, ndk).then((userRelations) => {
network.set(user, userRelations)
})
)
await Promise.all(promises)
// add all the following users to WOT
follows.forEach((f) => {
WOT.add(f)
})
// construct a list of users not being followed directly
const indirectFollows = new Set<Hexpubkey>()
follows.forEach((hexpubkey) => {
const relations = network.get(hexpubkey)
if (relations) {
relations.follows.forEach((f) => {
indirectFollows.add(f)
})
}
})
indirectFollows.forEach((targetHexPubkey) => {
// if any of the indirect followed user is in direct mute list
// we'll not include it in WOT
if (userRelations.muted.has(targetHexPubkey)) return
const wotScore = calculateWoTScore(pubkey, targetHexPubkey, network)
if (wotScore >= targetWOTScore) {
WOT.add(targetHexPubkey)
}
})
return WOT
}
export const calculateWoTScore = (
user: Hexpubkey,
targetUser: Hexpubkey,
network: Network
): number => {
const userRelations = network.get(user)
if (!userRelations) return 0
let wotScore = 0
// Check each user followed
for (const followedUser of userRelations.follows) {
const followedUserRelations = network.get(followedUser)
if (!followedUserRelations) continue
// Positive Score: +1 if followedUser also follows targetId
if (followedUserRelations.follows.has(targetUser)) {
wotScore += 1
}
// Negative Score: -1 if followedUser has muted targetId
if (followedUserRelations.muted.has(targetUser)) {
wotScore -= 1
}
}
return wotScore
}
export const findFollowsAndMuteUsers = async (
pubkey: string,
ndk: NDK
): Promise<UserRelations> => {
const follows = new Set<Hexpubkey>()
const muted = new Set<Hexpubkey>()
const filter: NDKFilter = {
kinds: [NDKKind.Contacts, NDKKind.MuteList],
authors: [pubkey]
}
const events = await ndk.fetchEvents(filter, {
groupable: false,
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
events.forEach((event) => {
if (event.kind === NDKKind.Contacts) {
filterValidPTags(event.tags).forEach((f) => {
follows.add(f)
})
}
})
events.forEach((event) => {
if (event.kind === NDKKind.MuteList) {
filterValidPTags(event.tags).forEach((f) => {
muted.add(f)
})
}
})
return {
follows,
muted
}
}
export const filterValidPTags = (tags: NDKTag[]) =>
tags
.filter((t: NDKTag) => t[0] === 'p')
.map((t: NDKTag) => t[1])
.filter((f: Hexpubkey) => {
try {
nip19.npubEncode(f)
return true
} catch {
return false
}
})

1
src/vite-env.d.ts vendored
View File

@ -4,6 +4,7 @@ interface ImportMetaEnv {
readonly VITE_APP_RELAY: string readonly VITE_APP_RELAY: string
readonly VITE_ADMIN_NPUBS: string readonly VITE_ADMIN_NPUBS: string
readonly VITE_REPORTING_NPUB: string readonly VITE_REPORTING_NPUB: string
readonly VITE_SITE_WOT_NPUB: string
readonly VITE_FALLBACK_MOD_IMAGE: string readonly VITE_FALLBACK_MOD_IMAGE: string
readonly VITE_FALLBACK_GAME_IMAGE: string readonly VITE_FALLBACK_GAME_IMAGE: string
// more env variables... // more env variables...