diff --git a/.env.example b/.env.example index 83ddd81..7c6fe65 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 b2ba757..3f4b38b 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 echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env diff --git a/.gitea/workflows/release-staging.yaml b/.gitea/workflows/release-staging.yaml index 683d4d1..41c5d3e 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 echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env diff --git a/.github/workflows/release-pages-production.yaml b/.github/workflows/release-pages-production.yaml index 93cdf1e..84fed7e 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 292f3a6..2a5384c 100644 --- a/src/components/ModsFilter.tsx +++ b/src/components/ModsFilter.tsx @@ -1,6 +1,12 @@ import { useAppSelector, useLocalStorage } from 'hooks' import React from 'react' -import { FilterOptions, ModeratedFilter, NSFWFilter, SortBy } from 'types' +import { + FilterOptions, + ModeratedFilter, + NSFWFilter, + SortBy, + WOTFilterOptions +} from 'types' import { DEFAULT_FILTER_OPTIONS } from 'utils' type Props = { @@ -19,6 +25,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 = + author && + userState.auth && + userState.user?.pubkey === 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 8d724ec..3752caf 100644 --- a/src/types/modsFilter.ts +++ b/src/types/modsFilter.ts @@ -17,9 +17,17 @@ 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 } diff --git a/src/utils/consts.ts b/src/utils/consts.ts index ff8e47b..611a3a8 100644 --- a/src/utils/consts.ts +++ b/src/utils/consts.ts @@ -1,8 +1,15 @@ -import { FilterOptions, SortBy, NSFWFilter, ModeratedFilter } from 'types' +import { + FilterOptions, + SortBy, + NSFWFilter, + ModeratedFilter, + WOTFilterOptions +} from 'types' export const DEFAULT_FILTER_OPTIONS: FilterOptions = { sort: SortBy.Latest, nsfw: NSFWFilter.Hide_NSFW, source: window.location.host, - moderated: ModeratedFilter.Moderated + moderated: ModeratedFilter.Moderated, + wot: WOTFilterOptions.Site_Only } 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 6363e3e..b4b6321 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 readonly VITE_BLOG_NPUBS: string