From 0aac63d96889b81ff92569d3e643eeebde2bc38e Mon Sep 17 00:00:00 2001 From: daniyal Date: Mon, 11 Nov 2024 22:37:49 +0500 Subject: [PATCH] feat: implemented WOT --- .env.example | 3 + .gitea/workflows/release-production.yaml | 1 + .gitea/workflows/release-staging.yaml | 1 + .../workflows/release-pages-production.yaml | 1 + src/components/ModsFilter.tsx | 70 +++- src/contexts/NDKContext.tsx | 2 +- src/hooks/useFilteredMods.ts | 30 +- src/layout/header.tsx | 2 + src/layout/index.tsx | 112 ++++++- src/pages/game.tsx | 6 +- src/pages/home.tsx | 7 +- src/pages/mods.tsx | 6 +- src/pages/profile.tsx | 4 +- src/pages/search.tsx | 6 +- src/pages/settings/preference.tsx | 317 +++++++++++------- src/store/index.ts | 4 +- src/store/reducers/wot.ts | 84 +++++ src/types/modsFilter.ts | 8 + src/utils/wot.ts | 147 ++++++++ src/vite-env.d.ts | 1 + 20 files changed, 686 insertions(+), 126 deletions(-) create mode 100644 src/store/reducers/wot.ts create mode 100644 src/utils/wot.ts diff --git a/.env.example b/.env.example index e6b55e6..4fade8b 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ VITE_ADMIN_NPUBS= # A dedicated npub used for reporting mods, blogs, profile and etc. VITE_REPORTING_NPUB= +# A dedicated npub used for site WOT. +VITE_SITE_WOT_NPUB= + # 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 diff --git a/.gitea/workflows/release-production.yaml b/.gitea/workflows/release-production.yaml index 981e1be..25ac9ab 100644 --- a/.gitea/workflows/release-production.yaml +++ b/.gitea/workflows/release-production.yaml @@ -25,6 +25,7 @@ jobs: echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .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_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env cat .env diff --git a/.gitea/workflows/release-staging.yaml b/.gitea/workflows/release-staging.yaml index 10e4bc4..01bc5ff 100644 --- a/.gitea/workflows/release-staging.yaml +++ b/.gitea/workflows/release-staging.yaml @@ -25,6 +25,7 @@ jobs: echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .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_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env cat .env diff --git a/.github/workflows/release-pages-production.yaml b/.github/workflows/release-pages-production.yaml index a80541b..10d4fde 100644 --- a/.github/workflows/release-pages-production.yaml +++ b/.github/workflows/release-pages-production.yaml @@ -32,6 +32,7 @@ jobs: run: | echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .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_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env diff --git a/src/components/ModsFilter.tsx b/src/components/ModsFilter.tsx index 755ff95..ab83fa7 100644 --- a/src/components/ModsFilter.tsx +++ b/src/components/ModsFilter.tsx @@ -1,7 +1,13 @@ import { useAppSelector } from 'hooks' import React from 'react' import { Dispatch, SetStateAction } from 'react' -import { FilterOptions, ModeratedFilter, NSFWFilter, SortBy } from 'types' +import { + FilterOptions, + ModeratedFilter, + NSFWFilter, + SortBy, + WOTFilterOptions +} from 'types' type Props = { filterOptions: FilterOptions @@ -15,6 +21,7 @@ export const ModFilter = React.memo( return (
+ {/* sort filter options */}
+ + {/* moderation filter options */}
+ + {/* wot filter options */} +
+
+ +
+ {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 ( +
+ setFilterOptions((prev) => ({ + ...prev, + wot: item + })) + } + > + {item} +
+ ) + })} +
+
+
+ + {/* nsfw filter options */}
+ + {/* source filter options */}
-
- - -
-
- - -
-
-
-
-

Not Safe For Work (NSFW)

-
-
- - -
-
-
-
-

Web of Trust (WoT) level

-
-

- This affects what posts you see, reactions, DMs, and - notifications. Learn more: Link -

-
- -

10

-
-
- - -
-
-
-
- + ) } diff --git a/src/store/index.ts b/src/store/index.ts index 13ced19..d9f509f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,9 +1,11 @@ import { configureStore } from '@reduxjs/toolkit' import userReducer from './reducers/user' +import wotReducer from './reducers/wot' export const store = configureStore({ reducer: { - user: userReducer + user: userReducer, + wot: wotReducer } }) diff --git a/src/store/reducers/wot.ts b/src/store/reducers/wot.ts new file mode 100644 index 0000000..87a3881 --- /dev/null +++ b/src/store/reducers/wot.ts @@ -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) { + state = { + ...state, + siteWot: action.payload, + siteWotStatus: WOTStatus.LOADED + } + return state + }, + setUserWot(state, action: PayloadAction) { + state = { + ...state, + userWot: action.payload, + userWotStatus: WOTStatus.LOADED + } + return state + }, + setSiteWotStatus(state, action: PayloadAction) { + return { + ...state, + siteWotStatus: action.payload + } + }, + setUserWotStatus(state, action: PayloadAction) { + return { + ...state, + userWotStatus: action.payload + } + }, + setSiteWotLevel(state, action: PayloadAction) { + return { + ...state, + siteWotLevel: action.payload + } + }, + setUserWotLevel(state, action: PayloadAction) { + return { + ...state, + userWotLevel: action.payload + } + } + } +}) + +export const { + setSiteWot, + setUserWot, + setSiteWotStatus, + setUserWotStatus, + setSiteWotLevel, + setUserWotLevel +} = wotSlice.actions + +export default wotSlice.reducer diff --git a/src/types/modsFilter.ts b/src/types/modsFilter.ts index a10542d..9334bfa 100644 --- a/src/types/modsFilter.ts +++ b/src/types/modsFilter.ts @@ -17,10 +17,18 @@ export enum ModeratedFilter { 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 { sort: SortBy nsfw: NSFWFilter source: string moderated: ModeratedFilter + wot: WOTFilterOptions author?: string } diff --git a/src/utils/wot.ts b/src/utils/wot.ts new file mode 100644 index 0000000..f894f96 --- /dev/null +++ b/src/utils/wot.ts @@ -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 + muted: Set +} + +type Network = Map + +export const calculateWot = async ( + pubkey: Hexpubkey, + ndk: NDK, + targetWOTScore: number +) => { + const network: Network = new Map() + const WOT = new Set() + + 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() + 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 => { + const follows = new Set() + const muted = new Set() + + 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 + } + }) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 1f3c47c..c80ac45 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -4,6 +4,7 @@ interface ImportMetaEnv { readonly VITE_APP_RELAY: string readonly VITE_ADMIN_NPUBS: string readonly VITE_REPORTING_NPUB: string + readonly VITE_SITE_WOT_NPUB: string readonly VITE_FALLBACK_MOD_IMAGE: string readonly VITE_FALLBACK_GAME_IMAGE: string // more env variables...