Add Profile tabs

This commit is contained in:
Bojan Mojsilovic 2023-09-27 12:39:02 +02:00
parent 39d70b9dc1
commit 12826817f2
14 changed files with 900 additions and 96 deletions

View File

@ -2,14 +2,19 @@ import { useIntl } from '@cookbook/solid-intl';
import { Component, Show } from 'solid-js';
import { useAccountContext } from '../../contexts/AccountContext';
import { hookForDev } from '../../lib/devTools';
import { account as t } from '../../translations';
import { account as t, actions } from '../../translations';
import { PrimalUser } from '../../types/primal';
import { useToastContext } from '../Toaster/Toaster';
import styles from './FollowButton.module.scss';
const FollowButton: Component<{ person: PrimalUser | undefined, large?: boolean, id?: string }> = (props) => {
const FollowButton: Component<{
person: PrimalUser | undefined,
large?: boolean,
id?: string,
postAction?: (remove: boolean, pubkey: string) => void,
}> = (props) => {
const toast = useToastContext()
const account = useAccountContext();
@ -32,7 +37,7 @@ const FollowButton: Component<{ person: PrimalUser | undefined, large?: boolean,
account.actions.removeFollow :
account.actions.addFollow;
action(props.person.pubkey);
action(props.person.pubkey, props.postAction);
}
const klass = () => {
@ -42,7 +47,7 @@ const FollowButton: Component<{ person: PrimalUser | undefined, large?: boolean,
return (
<Show when={props.person}>
<div id={props.id} class={klass()}>
<button onClick={onFollow} >
<button onClick={onFollow} disabled={account?.followInProgress === props.person?.pubkey}>
<Show
when={isFollowed()}
fallback={intl.formatMessage(t.follow)}

View File

@ -0,0 +1,53 @@
.profileContact {
display: flex;
align-items: center;
justify-content: stretch;
border-bottom: 1px solid var(--background-input);
padding-block: 12px;
.info {
display: flex;
flex-grow: 8;
justify-content: space-between;
text-decoration: none;
padding-left: 8px;
padding-right: 16px;
.profileInfo {
display: flex;
flex-direction: column;
.name {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
line-height: 16px;
}
.nip05 {
color: var(--text-tertiary-2);
font-size: 14px;
font-weight: 400;
line-height: 14px;
}
}
.stats {
.number {
color: var(--text-primary);
text-align: right;
font-size: 14px;
font-weight: 700;
line-height: 16px;
}
.label {
color: var(--text-tertiary-2);
text-align: right;
font-size: 14px;
font-weight: 400;
line-height: 14px;
}
}
}
}

View File

@ -0,0 +1,64 @@
import { Component, For, Show } from 'solid-js';
import {
PrimalNote,
PrimalUser
} from '../../types/primal';
import styles from './ProfileContact.module.scss';
import SmallNote from '../SmallNote/SmallNote';
import { useIntl } from '@cookbook/solid-intl';
import { nip05Verification, userName } from '../../stores/profile';
import { profile, profile as t } from '../../translations';
import { hookForDev } from '../../lib/devTools';
import Avatar from '../Avatar/Avatar';
import FollowButton from '../FollowButton/FollowButton';
import { A } from '@solidjs/router';
import { humanizeNumber } from '../../lib/stats';
const ProfileContact: Component<{
profile: PrimalUser | undefined,
profileStats: any,
postAction?: (remove: boolean, pubkey: string) => void,
id?: string,
}> = (props) => {
return (
<div id={props.id} class={styles.profileContact}>
<Avatar src={props.profile?.picture} size="sm" />
<A href={`/p/${props.profile?.npub}`} class={styles.info}>
<div class={styles.profileInfo}>
<div class={styles.name}>{userName(props.profile)}</div>
<div class={styles.nip05}>
<Show when={props.profile?.nip05}>
<span
class={styles.verifiedBy}
title={props.profile?.nip05}
>
{nip05Verification(props.profile)}
</span>
</Show>
</div>
</div>
<Show when={props.profileStats}>
<div class={styles.stats}>
<div class={styles.number}>
{humanizeNumber(props.profileStats)}
</div>
<div class={styles.label}>
followers
</div>
</div>
</Show>
</A>
<div class={styles.action}>
<FollowButton person={props.profile} postAction={props.postAction} />
</div>
</div>
);
}
export default hookForDev(ProfileContact);

View File

@ -0,0 +1,64 @@
.profileTabs {
position: relative;
display: flex;
flex-direction: row;
justify-content: left;
align-items: center;
padding-inline: 8px;
background-color: var(--background-card);
width: 100%;
border-radius: 0 0 8px 8px;
}
.profileTab {
position: relative;
display: inline-block;
padding-inline: 14px;
padding-block: 2px;
border: none;
background: none;
width: fit-content;
height: 40px;
margin: 0;
margin-bottom: 12px;
&:focus {
outline: none;
box-shadow: none;
}
}
.profileTabIndicator {
position: absolute;
height: 4px;
top: 48px;
left: 0;
border-radius: 2px 2px 0px 0px;
background: linear-gradient(0deg, #CA079F 0%, #CA079F 100%), linear-gradient(134deg, #EF404A 0%, #5B12A4 100%), #121212;
transition: all 250ms;
}
.stat {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.statNumber {
font-weight: 400;
font-size: 24px;
line-height: 24px;
color: var(--text-primary);
}
.statName {
font-weight: 300;
font-size: 14px;
line-height: 16px;
color: var(--text-tertiary-2);
text-transform: lowercase;
}
}
.profileNotes {
position: relative;
}

View File

@ -0,0 +1,284 @@
import { useIntl } from "@cookbook/solid-intl";
import { Tabs } from "@kobalte/core";
import { Component, For, Match, onMount, Switch } from "solid-js";
import { useAccountContext } from "../../contexts/AccountContext";
import { useProfileContext } from "../../contexts/ProfileContext";
import { hookForDev } from "../../lib/devTools";
import { humanizeNumber } from "../../lib/stats";
import { userName } from "../../stores/profile";
import { profile as t, actions as tActions } from "../../translations";
import Loader from "../Loader/Loader";
import Note from "../Note/Note";
import Paginator from "../Paginator/Paginator";
import ProfileContact from "../ProfileContact/ProfileContact";
import styles from "./ProfileTabs.module.scss";
const ProfileTabs: Component<{
id?: string,
setProfile?: (pk: string) => void,
}> = (props) => {
const intl = useIntl();
const profile = useProfileContext();
const account = useAccountContext();
const addToAllowlist = async () => {
const pk = profile?.profileKey;
const setP = props.setProfile;
if (pk && setP) {
account?.actions.addToAllowlist(pk, () => { setP(pk) });
}
};
const isMuted = (pk: string | undefined, ignoreContentCheck = false) => {
const isContentMuted = account?.mutelists.find(x => x.pubkey === account.publicKey)?.content;
return pk &&
account?.muted.includes(pk) &&
(ignoreContentCheck ? true : isContentMuted);
};
const isFiltered = () => {
return profile?.filterReason !== null;
};
const unMuteProfile = () => {
const pk = profile?.profileKey;
const setP = props.setProfile;
if (!account || !pk || !setP) {
return;
}
account.actions.removeFromMuteList(pk, () => setP(pk));
};
const onContactAction = (remove: boolean, pubkey: string) => {
if (remove) {
profile?.actions.removeContact(pubkey);
}
else {
profile?.actions.addContact(pubkey, profile.followers);
}
};
const contacts = () => {
const cts = [...(profile?.contacts || [])];
cts.sort((a, b) => {
const aFollowers = profile?.profileStats[a.pubkey] || 0;
const bFollowers = profile?.profileStats[b.pubkey] || 0;
const c = bFollowers >= aFollowers ? 1 : -1;
return c;
});
return cts;
}
const followers = () => {
const fls = [...(profile?.followers || [])];
fls.sort((a, b) => {
const aFollowers = profile?.profileStats[a.pubkey] || 0;
const bFollowers = profile?.profileStats[b.pubkey] || 0;
const c = bFollowers >= aFollowers ? 1 : -1;
return c;
});
return fls;
}
return (
<Tabs.Root aria-label="Main navigation">
<Tabs.List class={styles.profileTabs}>
<Tabs.Trigger class={styles.profileTab} value="notes">
<div class={styles.stat}>
<div class={styles.statNumber}>
{humanizeNumber(profile?.userStats?.note_count || 0)}
</div>
<div class={styles.statName}>
{intl.formatMessage(t.stats.notes)}
</div>
</div>
</Tabs.Trigger>
<Tabs.Trigger class={styles.profileTab} value="replies">
<div class={styles.stat}>
<div class={styles.statNumber}>
{humanizeNumber(profile?.userStats?.reply_count || 0)}
</div>
<div class={styles.statName}>
{intl.formatMessage(t.stats.replies)}
</div>
</div>
</Tabs.Trigger>
<Tabs.Trigger class={styles.profileTab} value="follows">
<div class={styles.stat}>
<div class={styles.statNumber}>
{humanizeNumber(profile?.contacts.length || profile?.userStats?.follows_count || 0)}
</div>
<div class={styles.statName}>
{intl.formatMessage(t.stats.follow)}
</div>
</div>
</Tabs.Trigger>
<Tabs.Trigger class={styles.profileTab} value="followers">
<div class={styles.stat}>
<div class={styles.statNumber}>
{humanizeNumber(profile?.followers.length || profile?.userStats?.followers_count || 0)}
</div>
<div class={styles.statName}>
{intl.formatMessage(t.stats.followers)}
</div>
</div>
</Tabs.Trigger>
<Tabs.Indicator class={styles.profileTabIndicator} />
</Tabs.List>
<Tabs.Content class={styles.tabContent} value="notes">
<div class={styles.profileNotes}>
<Switch
fallback={
<div style="margin-top: 40px;">
<Loader />
</div>
}>
<Match when={isMuted(profile?.profileKey)}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.isMuted,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
<button
onClick={unMuteProfile}
>
{intl.formatMessage(tActions.unmute)}
</button>
</div>
</Match>
<Match when={isFiltered()}>
<div class={styles.mutedProfile}>
{intl.formatMessage(t.isFiltered)}
<button
onClick={addToAllowlist}
>
{intl.formatMessage(tActions.addToAllowlist)}
</button>
</div>
</Match>
<Match when={profile && profile.notes.length === 0 && !profile.isFetching}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.noNotes,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
</div>
</Match>
<Match when={profile && profile.notes.length > 0}>
<For each={profile?.notes}>
{note => (
<Note note={note} />
)}
</For>
<Paginator loadNextPage={() => {
profile?.actions.fetchNextPage();
}}/>
</Match>
</Switch>
</div>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value="replies">
<div class={styles.profileNotes}>
<Switch
fallback={
<div style="margin-top: 40px;">
<Loader />
</div>
}>
<Match when={isMuted(profile?.profileKey)}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.isMuted,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
<button
onClick={unMuteProfile}
>
{intl.formatMessage(tActions.unmute)}
</button>
</div>
</Match>
<Match when={isFiltered()}>
<div class={styles.mutedProfile}>
{intl.formatMessage(t.isFiltered)}
<button
onClick={addToAllowlist}
>
{intl.formatMessage(tActions.addToAllowlist)}
</button>
</div>
</Match>
<Match when={profile && profile.replies.length === 0 && !profile.isFetchingReplies}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.noNotes,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
</div>
</Match>
<Match when={profile && profile.replies.length > 0}>
<For each={profile?.replies}>
{reply => (
<Note note={reply} />
)}
</For>
<Paginator loadNextPage={() => {
profile?.actions.fetchNextRepliesPage();
}}/>
</Match>
</Switch>
</div>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value="follows">
<For each={contacts()}>
{contact =>
<div>
<ProfileContact
profile={contact}
profileStats={profile?.profileStats[contact.pubkey]}
postAction={onContactAction}
/>
</div>}
</For>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value="followers">
<For each={followers()}>
{follower =>
<div>
<ProfileContact
profile={follower}
profileStats={profile?.profileStats[follower.pubkey]}
postAction={onContactAction}
/>
</div>
}
</For>
</Tabs.Content>
</Tabs.Root>
);
}
export default hookForDev(ProfileTabs);

View File

@ -129,6 +129,7 @@ export enum Kind {
ImportResponse = 10_000_127,
LinkMetadata = 10_000_128,
FilteringReason = 10_000_131,
UserFollowerCounts= 10_000_133,
}
export const relayConnectingTimeout = 1000;

View File

@ -2,7 +2,7 @@ import { createStore, unwrap } from "solid-js/store";
import {
createContext,
createEffect,
JSX,
JSXElement,
onCleanup,
onMount,
useContext
@ -39,6 +39,7 @@ export type AccountContextStore = {
showNewNoteForm: boolean,
following: string[],
followingSince: number,
followInProgress: string,
muted: string[],
mutedPrivate: string,
mutedSince: number,
@ -57,8 +58,8 @@ export type AccountContextStore = {
setActiveUser: (user: PrimalUser) => void,
addLike: (note: PrimalNote) => Promise<boolean>,
setPublicKey: (pubkey: string | undefined) => void,
addFollow: (pubkey: string) => void,
removeFollow: (pubkey: string) => void,
addFollow: (pubkey: string, cb?: (remove: boolean, pubkey: string) => void) => void,
removeFollow: (pubkey: string, cb?: (remove: boolean, pubkey: string) => void) => void,
quoteNote: (noteId: string | undefined) => void,
addToMuteList: (pubkey: string) => void,
removeFromMuteList: (pubkey: string, then?: () => void) => void,
@ -86,6 +87,7 @@ const initialData = {
showNewNoteForm: false,
following: [],
followingSince: 0,
followInProgress: '',
muted: [],
mutedPrivate: '',
mutedSince: 0,
@ -101,7 +103,7 @@ const initialData = {
export const AccountContext = createContext<AccountContextStore>();
export function AccountProvider(props: { children: number | boolean | Node | JSX.ArrayElement | JSX.FunctionElement | (string & {}) | null | undefined; }) {
export function AccountProvider(props: { children: JSXElement }) {
let relayAtempts: Record<string, number> = {};
const relayAtemptLimit = 10;
@ -435,11 +437,13 @@ export function AccountProvider(props: { children: number | boolean | Node | JSX
saveMuteList(store.publicKey, muted, content.content, mutedSince || 0);
};
const addFollow = (pubkey: string) => {
const addFollow = (pubkey: string, cb?: (remove: boolean, pubkey: string) => void) => {
if (!store.publicKey || store.following.includes(pubkey)) {
return;
}
updateStore('followInProgress', () => pubkey);
const unsub = subscribeTo(`before_follow_${APP_ID}`, async (type, subId, content) => {
if (type === 'EOSE') {
@ -458,9 +462,11 @@ export function AccountProvider(props: { children: number | boolean | Node | JSX
updateStore('followingSince', () => date);
updateStore('contactsTags', () => [...tags]);
saveFollowing(store.publicKey, following, date);
cb && cb(false, pubkey);
}
}
updateStore('followInProgress', () => '');
unsub();
return;
}
@ -478,11 +484,13 @@ export function AccountProvider(props: { children: number | boolean | Node | JSX
}
const removeFollow = (pubkey: string) => {
const removeFollow = (pubkey: string, cb?: (remove: boolean, pubkey: string) => void) => {
if (!store.publicKey || !store.following.includes(pubkey)) {
return;
}
updateStore('followInProgress', () => pubkey);
const unsub = subscribeTo(`before_unfollow_${APP_ID}`, async (type, subId, content) => {
if (type === 'EOSE') {
if (store.following.includes(pubkey)) {
@ -500,9 +508,11 @@ export function AccountProvider(props: { children: number | boolean | Node | JSX
updateStore('followingSince', () => date);
updateStore('contactsTags', () => [...tags]);
saveFollowing(store.publicKey, following, date);
cb && cb(true, pubkey);
}
}
updateStore('followInProgress', () => '');
unsub();
return;
}

View File

@ -1,5 +1,5 @@
import { nip19 } from "nostr-tools";
import { createStore } from "solid-js/store";
import { createStore, reconcile } from "solid-js/store";
import { getEvents, getFutureUserFeed, getUserFeed } from "../lib/feed";
import { convertToNotes, paginationPlan, parseEmptyReposts, sortByRecency, sortByScore } from "../stores/note";
import { Kind } from "../constants";
@ -34,12 +34,16 @@ import {
import { APP_ID } from "../App";
import { hexToNpub } from "../lib/keys";
import {
getProfileContactList,
getProfileFollowerList,
getProfileScoredNotes,
getUserProfileInfo,
getUserProfiles,
isUserFollowing,
} from "../lib/profile";
import { useAccountContext } from "./AccountContext";
import { setLinkPreviews } from "../lib/notes";
import { subscribeTo } from "../sockets";
export type ProfileContextStore = {
profileKey: string | undefined,
@ -48,33 +52,56 @@ export type ProfileContextStore = {
follows_count: number,
followers_count: number,
note_count: number,
reply_count: number,
time_joined: number,
},
knownProfiles: VanityProfiles,
notes: PrimalNote[],
replies: PrimalNote[],
future: {
notes: PrimalNote[],
page: FeedPage,
replies: PrimalNote[],
repliesPage: FeedPage,
reposts: Record<string, string> | undefined,
},
isProfileFollowing: boolean,
isFetching: boolean,
isFetchingReplies: boolean,
page: FeedPage,
repliesPage: FeedPage,
reposts: Record<string, string> | undefined,
lastNote: PrimalNote | undefined,
lastReply: PrimalNote | undefined,
following: string[],
sidebar: FeedPage & { notes: PrimalNote[] },
filterReason: { action: 'block' | 'allow', pubkey?: string, group?: string } | null,
contacts: PrimalUser[],
followers: PrimalUser[],
isFetchingContacts: boolean,
isFetchingFollowers: boolean,
profileStats: Record<string, number>,
actions: {
saveNotes: (newNotes: PrimalNote[]) => void,
clearNotes: () => void,
saveReplies: (newNotes: PrimalNote[]) => void,
clearReplies: () => void,
fetchReplies: (noteId: string | undefined, until?: number) => void,
fetchNextRepliesPage: () => void,
fetchNotes: (noteId: string | undefined, until?: number) => void,
fetchNextPage: () => void,
updatePage: (content: NostrEventContent) => void,
savePage: (page: FeedPage) => void,
updateRepliesPage: (content: NostrEventContent) => void,
saveRepliesPage: (page: FeedPage) => void,
setProfileKey: (profileKey?: string) => void,
refreshNotes: () => void,
checkForNewNotes: (pubkey: string | undefined) => void,
fetchContactList: (pubkey: string | undefined) => void,
fetchFollowerList: (pubkey: string | undefined) => void,
clearContacts: () => void,
removeContact: (pubkey: string) => void,
addContact: (pubkey: string, source: PrimalUser[]) => void,
}
}
@ -82,6 +109,7 @@ export const emptyStats = {
follows_count: 0,
followers_count: 0,
note_count: 0,
reply_count: 0,
time_joined: 0,
};
@ -91,13 +119,22 @@ export const initialData = {
userStats: { ...emptyStats },
knownProfiles: { names: {} },
notes: [],
replies: [],
isFetching: false,
isFetchingReplies: false,
isProfileFollowing: false,
page: { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {} },
repliesPage: { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {} },
reposts: {},
lastNote: undefined,
lastReply: undefined,
following: [],
filterReason: null,
contacts: [],
profileStats: {},
isFetchingContacts: false,
followers: [],
isFetchingFollowers: false,
sidebar: {
messages: [],
users: {},
@ -107,6 +144,7 @@ export const initialData = {
},
future: {
notes: [],
replies: [],
reposts: {},
page: {
messages: [],
@ -115,6 +153,13 @@ export const initialData = {
mentions: {},
noteActions: {},
},
repliesPage: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
},
},
};
@ -127,6 +172,126 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
// ACTIONS --------------------------------------
const addContact = (pubkey: string, source: PrimalUser[]) => {
const newContact = source.find(c => c.pubkey === pubkey);
newContact && updateStore('contacts', store.contacts.length, reconcile(newContact));
};
const removeContact = (pubkey: string) => {
const newContacts = store.contacts.filter(c => c.pubkey !== pubkey);
updateStore('contacts', reconcile(newContacts));
};
const fetchContactList = (pubkey: string | undefined) => {
if (!pubkey) return;
updateStore('isFetchingContacts', () => true);
const subIdContacts = `profile_contacts_${APP_ID}`;
const subIdProfiles = `profile_contacts_2_${APP_ID}`;
let ids: string[] = [];
const unsubProfiles = subscribeTo(subIdProfiles, (type, _, content) => {
if (type === 'EOSE') {
updateStore('isFetchingContacts', () => false);
unsubProfiles();
return;
}
if (type === 'EVENT') {
if (content?.kind === Kind.Metadata) {
let user = JSON.parse(content.content);
if (!user.displayName || typeof user.displayName === 'string' && user.displayName.trim().length === 0) {
user.displayName = user.display_name;
}
user.pubkey = content.pubkey;
user.npub = hexToNpub(content.pubkey);
user.created_at = content.created_at;
updateStore('contacts', store.contacts.length, () => ({ ...user }));
return;
}
if (content?.kind === Kind.UserFollowerCounts) {
const stats = JSON.parse(content.content);
updateStore('profileStats', () => ({ ...stats }));
return;
}
}
});
const unsubContacts = subscribeTo(subIdContacts, (type, _, content) => {
if (type === 'EOSE') {
getUserProfiles(ids, subIdProfiles);
unsubContacts();
return;
}
if (type === 'EVENT') {
if (content && content.kind === Kind.Contacts) {
const tags = content.tags;
for (let i = 0;i<tags.length;i++) {
const tag = tags[i];
if (tag[0] === 'p') {
ids.push(tag[1]);
}
}
}
}
});
getProfileContactList(pubkey, subIdContacts);
};
const fetchFollowerList = (pubkey: string | undefined) => {
if (!pubkey) return;
updateStore('isFetchingFollowers', () => true);
const subIdProfiles = `profile_followers_2_${APP_ID}`;
const unsubProfiles = subscribeTo(subIdProfiles, (type, _, content) => {
if (type === 'EOSE') {
updateStore('isFetchingFollowers', () => false);
unsubProfiles();
return;
}
if (type === 'EVENT') {
if (content?.kind === Kind.Metadata) {
let user = JSON.parse(content.content);
if (!user.displayName || typeof user.displayName === 'string' && user.displayName.trim().length === 0) {
user.displayName = user.display_name;
}
user.pubkey = content.pubkey;
user.npub = hexToNpub(content.pubkey);
user.created_at = content.created_at;
updateStore('followers', store.followers.length, () => ({ ...user }));
return;
}
if (content?.kind === Kind.UserFollowerCounts) {
const stats: Record<string, number> = JSON.parse(content.content);
updateStore('profileStats', () => ({ ...stats }));
return;
}
}
});
getProfileFollowerList(pubkey, subIdProfiles);
};
const saveNotes = (newNotes: PrimalNote[], scope?: 'future') => {
if (scope) {
updateStore(scope, 'notes', (notes) => [ ...notes, ...newNotes ]);
@ -137,6 +302,16 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
updateStore('isFetching', () => false);
};
const saveReplies = (newNotes: PrimalNote[], scope?: 'future') => {
if (scope) {
updateStore(scope, 'replies', (notes) => [ ...notes, ...newNotes ]);
loadFutureContent();
return;
}
updateStore('replies', (notes) => [ ...notes, ...newNotes ]);
updateStore('isFetchingReplies', () => false);
};
const fetchNotes = (pubkey: string | undefined, until = 0, limit = 20) => {
if (!pubkey) {
return;
@ -144,7 +319,17 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
updateStore('isFetching', () => true);
updateStore('page', () => ({ messages: [], users: {}, postStats: {} }));
getUserFeed(account?.publicKey, pubkey, `profile_feed_${APP_ID}`, until, limit);
getUserFeed(account?.publicKey, pubkey, `profile_feed_${APP_ID}`, 'authored', until, limit);
}
const fetchReplies = (pubkey: string | undefined, until = 0, limit = 20) => {
if (!pubkey) {
return;
}
updateStore('isFetching', () => true);
updateStore('page', () => ({ messages: [], users: {}, postStats: {} }));
getUserFeed(account?.publicKey, pubkey, `profile_replies_${APP_ID}`, 'replies', until, limit);
}
const clearNotes = () => {
@ -161,6 +346,20 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
}));
};
const clearReplies = () => {
updateStore('repliesPage', () => ({ messages: [], users: {}, postStats: {}, noteActions: {} }));
updateStore('replies', () => []);
updateStore('lastReply', () => undefined);
};
const clearContacts = () => {
updateStore('contacts', () => []);
updateStore('followers', () => []);
// @ts-ignore
updateStore('profileStats', () => undefined);
updateStore('profileStats', () => ({}));
};
const fetchNextPage = () => {
const lastNote = store.notes[store.notes.length - 1];
@ -183,9 +382,32 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
}
};
const fetchNextRepliesPage = () => {
const lastReply = store.notes[store.notes.length - 1];
if (!lastReply) {
return;
}
updateStore('lastReply', () => ({ ...lastReply }));
const criteria = paginationPlan('latest');
const noteData: Record<string, any> = lastReply.repost ?
lastReply.repost.note :
lastReply.post;
const until = noteData[criteria];
if (until > 0 && store.profileKey) {
fetchReplies(store.profileKey, until);
}
};
const clearFuture = () => {
updateStore('future', () => ({
notes: [],
replies: [],
reposts: {},
page: {
messages: [],
@ -194,6 +416,13 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
mentions: {},
noteActions: {},
},
repliesPage: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
},
}))
}
@ -348,6 +577,124 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
};
const updateRepliesPage = (content: NostrEventContent, scope?: 'future') => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
if (scope) {
updateStore(scope, 'repliesPage', 'users',
() => ({ [user.pubkey]: { ...user } })
);
return;
}
updateStore('repliesPage', 'users',
() => ({ [user.pubkey]: { ...user } })
);
return;
}
if ([Kind.Text, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
const messageId = nip19.noteEncode(message.id);
if (scope) {
const isFirstReply = message.kind === Kind.Text ?
store.replies[0]?.post?.noteId === messageId :
store.replies[0]?.repost?.note.noteId === messageId;
if (!isFirstReply) {
updateStore(scope, 'repliesPage', 'messages',
(msgs) => [ ...msgs, { ...message }]
);
}
return;
}
const isLastReply = message.kind === Kind.Text ?
store.lastReply?.post?.noteId === messageId :
store.lastReply?.repost?.note.noteId === messageId;
if (!isLastReply) {
updateStore('repliesPage', 'messages', messages => [ ...messages, message]);
}
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
if (scope) {
updateStore(scope, 'repliesPage', 'postStats',
(stats) => ({ ...stats, [stat.event_id]: { ...stat } })
);
return;
}
updateStore('repliesPage', 'postStats', () => ({ [stat.event_id]: stat }));
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
if (scope) {
updateStore(scope, 'repliesPage', 'mentions',
() => ({ [mention.id]: { ...mention } })
);
return;
}
updateStore('repliesPage', 'mentions', () => ({ [mention.id]: { ...mention } }));
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
if (scope) {
updateStore(scope, 'repliesPage', 'noteActions',
() => ({ [noteActions.event_id]: { ...noteActions } })
);
return;
}
updateStore('repliesPage', 'noteActions', () => ({ [noteActions.event_id]: noteActions }));
return;
}
if (content.kind === Kind.LinkMetadata) {
const metadata = JSON.parse(content.content);
const data = metadata.resources[0];
if (!data) {
return;
}
const preview = {
url: data.url,
title: data.md_title,
description: data.md_description,
mediaType: data.mimetype,
contentType: data.mimetype,
images: [data.md_image],
favicons: [data.icon_url],
};
setLinkPreviews(() => ({ [data.url]: preview }));
return;
}
};
const saveRepliesPage = (page: FeedPage, scope?: 'future') => {
const newPosts = sortByRecency(convertToNotes(page));
saveReplies(newPosts, scope);
};
const updateSidebar = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
@ -444,6 +791,18 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
}
}
if (subId === `profile_replies_${APP_ID}`) {
if (type === 'EOSE') {
saveRepliesPage(store.repliesPage);
return;
}
if (type === 'EVENT') {
updateRepliesPage(content);
return;
}
}
if (subId === `profile_info_${APP_ID}`) {
if (content?.kind === Kind.FilteringReason) {
const reason: { action: 'block' | 'allow', pubkey?: string, group?: string } | null =
@ -625,9 +984,20 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
fetchNextPage,
updatePage,
savePage,
saveReplies,
clearReplies,
fetchReplies,
fetchNextRepliesPage,
updateRepliesPage,
saveRepliesPage,
setProfileKey,
refreshNotes,
checkForNewNotes,
fetchContactList,
fetchFollowerList,
clearContacts,
addContact,
removeContact,
},
});

View File

@ -62,14 +62,14 @@ export const getEvents = (user_pubkey: string | undefined, eventIds: string[], s
};
export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, until = 0, limit = 20) => {
export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, notes: 'authored' | 'replies', until = 0, limit = 20) => {
if (!pubkey) {
return;
}
const start = until === 0 ? 'since' : 'until';
let payload = { pubkey, limit, notes: 'authored', [start]: until } ;
let payload = { pubkey, limit, notes, [start]: until } ;
if (user_pubkey) {
payload.user_pubkey = user_pubkey;

View File

@ -60,6 +60,18 @@ export const getProfileContactList = (pubkey: string | undefined, subid: string,
]));
}
export const getProfileFollowerList = (pubkey: string | undefined, subid: string, extended = false) => {
if (!pubkey) {
return;
}
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["user_followers", { pubkey }]},
]));
}
export const getProfileMuteList = (pubkey: string | undefined, subid: string, extended?: boolean) => {
if (!pubkey) {
return;

View File

@ -2,7 +2,6 @@
position: relative;
background-color: var(--background-card);
padding-bottom: 20px;
border-radius: 0 0 8px 8px;
}
.banner {

View File

@ -41,6 +41,7 @@ import PrimalMenu from '../components/PrimalMenu/PrimalMenu';
import ConfirmModal from '../components/ConfirmModal/ConfirmModal';
import { isAccountVerified, reportUser } from '../lib/profile';
import { APP_ID } from '../App';
import ProfileTabs from '../components/ProfileTabs/ProfileTabs';
const Profile: Component = () => {
@ -87,8 +88,15 @@ const Profile: Component = () => {
const setProfile = (hex: string | undefined) => {
profile?.actions.setProfileKey(hex);
profile?.actions.clearNotes();
profile?.actions.clearReplies();
profile?.actions.clearContacts();
profile?.actions.fetchNotes(hex);
profile?.actions.fetchReplies(hex);
profile?.actions.fetchContactList(hex);
profile?.actions.fetchFollowerList(hex);
}
createEffect(() => {
@ -596,86 +604,9 @@ const Profile: Component = () => {
</div>
</div>
<div class={styles.userStats}>
<div class={styles.userStat}>
<div class={styles.statNumber}>
{humanizeNumber(profile?.userStats?.follows_count || 0)}
</div>
<div class={styles.statName}>
{intl.formatMessage(t.stats.follow)}
</div>
</div>
<div class={styles.userStat}>
<div class={styles.statNumber}>
{humanizeNumber(profile?.userStats?.followers_count || 0)}
</div>
<div class={styles.statName}>
{intl.formatMessage(t.stats.followers)}
</div>
</div>
<div class={styles.userStat}>
<div class={styles.statNumber}>
{humanizeNumber(profile?.userStats?.note_count || 0)}
</div>
<div class={styles.statName}>
{intl.formatMessage(t.stats.notes)}
</div>
</div>
</div>
</div>
<div class={styles.userFeed}>
<Switch
fallback={
<div style="margin-top: 40px;">
<Loader />
</div>
}>
<Match when={isMuted(profile?.profileKey)}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.isMuted,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
<button
onClick={unMuteProfile}
>
{intl.formatMessage(tActions.unmute)}
</button>
</div>
</Match>
<Match when={isFiltered()}>
<div class={styles.mutedProfile}>
{intl.formatMessage(t.isFiltered)}
<button
onClick={addToAllowlist}
>
{intl.formatMessage(tActions.addToAllowlist)}
</button>
</div>
</Match>
<Match when={profile && profile.notes.length === 0 && !profile.isFetching}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.noNotes,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
</div>
</Match>
<Match when={profile && profile.notes.length > 0}>
<For each={profile?.notes}>
{note => (
<Note note={note} />
)}
</For>
<Paginator loadNextPage={() => {
profile?.actions.fetchNextPage();
}}/>
</Match>
</Switch>
</div>
<ProfileTabs />
<ConfirmModal
open={confirmReportUser()}

View File

@ -831,6 +831,11 @@ export const profile = {
defaultMessage: 'Notes',
description: 'Label for notes profile stat',
},
replies: {
id: 'profile.stats.replies',
defaultMessage: 'Replies',
description: 'Label for replies profile stat',
},
},
isMuted: {
id: 'profile.isMuted',

12
src/types/primal.d.ts vendored
View File

@ -172,6 +172,12 @@ export type NostrFilteringReason = {
created_at?: number,
};
export type NostrUserFollwerCounts = {
kind: Kind.UserFollowerCounts,
content: string,
created_at?: number,
};
export type NostrEventContent =
NostrNoteContent |
NostrUserContent |
@ -195,7 +201,8 @@ export type NostrEventContent =
NostrMediaInfo |
NostrMediaUploaded |
NostrLinkMetadata |
NostrFilteringReason;
NostrFilteringReason |
NostrUserFollwerCounts;
export type NostrEvent = [
type: "EVENT",
@ -490,13 +497,12 @@ export type ContextChildren =
boolean |
Node |
JSX.ArrayElement |
JSX.FunctionElement |
(string & {}) | null | undefined;
export type PrimalTheme = { name: string, label: string, logo: string, dark?: boolean};
export type ChildrenProp = { children: number | boolean | Node | JSX.ArrayElement | JSX.FunctionElement | (string & {}) | null | undefined; };
export type ChildrenProp = { children: number | boolean | Node | JSX.ArrayElement | (string & {}) | null | undefined; };
export type VanityProfiles = { names: Record<string, string> };