Compare commits

...

5 Commits

Author SHA1 Message Date
Bojan Mojsilovic
6d23df743d Fix longform reactions 2024-06-21 16:56:12 +02:00
Bojan Mojsilovic
70872beb5e Fix footer compact 2024-06-21 14:17:18 +02:00
Bojan Mojsilovic
dd1ba52f38 Embedded note footer 2024-06-21 14:05:14 +02:00
Bojan Mojsilovic
01ebf6a6d6 Profile Gallery 2024-06-21 13:49:13 +02:00
Bojan Mojsilovic
09db5fbfb1 Fetch future articles 2024-06-20 14:56:11 +02:00
26 changed files with 943 additions and 54 deletions

6
package-lock.json generated
View File

@ -49,6 +49,7 @@
"medium-zoom": "1.0.8",
"nostr-tools": "1.15.0",
"photoswipe": "5.4.3",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"qr-code-styling": "^1.6.0-rc.1",
"remark-directive": "^3.0.0",
"sass": "1.67.0",
@ -8645,6 +8646,11 @@
"node": ">= 0.12.0"
}
},
"node_modules/photoswipe-dynamic-caption-plugin": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/photoswipe-dynamic-caption-plugin/-/photoswipe-dynamic-caption-plugin-1.2.7.tgz",
"integrity": "sha512-5XXdXLf2381nwe7KqQvcyStiUBi9TitYXppUQTrzPwYAi4lZsmWNnNKMclM7I4QGlX6fXo42v3bgb6rlK9pY1Q=="
},
"node_modules/picocolors": {
"version": "1.0.0",
"dev": true,

View File

@ -62,6 +62,7 @@
"medium-zoom": "1.0.8",
"nostr-tools": "1.15.0",
"photoswipe": "5.4.3",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"qr-code-styling": "^1.6.0-rc.1",
"remark-directive": "^3.0.0",
"sass": "1.67.0",

View File

@ -186,7 +186,7 @@ const ArticlePreview: Component<{
});
const openReactionModal = (openOn = 'likes') => {
app?.actions.openReactionModal(props.article.id, {
app?.actions.openReactionModal(props.article.naddr, {
likes: reactionsState.likes,
zaps: reactionsState.zapCount,
reposts: reactionsState.reposts,

View File

@ -1,15 +1,20 @@
import { useIntl } from '@cookbook/solid-intl';
import { A } from '@solidjs/router';
import { nip19 } from 'nostr-tools';
import { Component, createMemo, JSXElement, Show } from 'solid-js';
import { batch, Component, createMemo, JSXElement, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { useAccountContext } from '../../contexts/AccountContext';
import { CustomZapInfo, useAppContext } from '../../contexts/AppContext';
import { useMediaContext } from '../../contexts/MediaContext';
import { useThreadContext } from '../../contexts/ThreadContext';
import { date } from '../../lib/dates';
import { trimVerification } from '../../lib/profile';
import { nip05Verification, userName } from '../../stores/profile';
import { note as t } from '../../translations';
import { PrimalNote, PrimalUser } from '../../types/primal';
import { PrimalNote, PrimalUser, ZapOption } from '../../types/primal';
import Avatar from '../Avatar/Avatar';
import { NoteReactionsState } from '../Note/Note';
import NoteFooter from '../Note/NoteFooter/NoteFooter';
import ParsedNote from '../ParsedNote/ParsedNote';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
@ -26,9 +31,33 @@ const EmbeddedNote: Component<{
const threadContext = useThreadContext();
const intl = useIntl();
const app = useAppContext();
const account = useAccountContext();
let noteContent: HTMLDivElement | undefined;
const [reactionsState, updateReactionsState] = createStore<NoteReactionsState>({
likes: props.note?.post.likes || 0,
liked: props.note?.post.noteActions?.liked || false,
reposts: props.note?.post.reposts || 0,
reposted: props.note?.post.noteActions?.reposted || false,
replies: props.note?.post.replies || 0,
replied: props.note?.post.noteActions?.replied || false,
zapCount: props.note?.post.zaps || 0,
satsZapped: props.note?.post.satszapped || 0,
zapped: props.note?.post.noteActions?.zapped || false,
zappedAmount: 0,
zappedNow: false,
isZapping: false,
showZapAnim: false,
hideZapIcon: false,
moreZapsAvailable: false,
isRepostMenuVisible: false,
topZaps: [],
topZapsFeed: [],
quoteCount: 0,
});
const noteId = () => nip19.noteEncode(props.note?.post.id);
const navToThread = () => {
@ -75,6 +104,82 @@ const EmbeddedNote: Component<{
);
};
const onConfirmZap = (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
batch(() => {
updateReactionsState('zappedAmount', () => zapOption.amount || 0);
updateReactionsState('satsZapped', (z) => z + (zapOption.amount || 0));
updateReactionsState('zapped', () => true);
updateReactionsState('showZapAnim', () => true)
});
// addTopZap(zapOption);
// addTopZapFeed(zapOption)
};
const onSuccessZap = (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
app?.actions.resetCustomZap();
const pubkey = account?.publicKey;
if (!pubkey) return;
batch(() => {
updateReactionsState('zapCount', (z) => z + 1);
updateReactionsState('isZapping', () => false);
updateReactionsState('showZapAnim', () => false);
updateReactionsState('hideZapIcon', () => false);
updateReactionsState('zapped', () => true);
});
};
const onFailZap = (zapOption: ZapOption) => {
const note = props.note;
if (!note) return;
app?.actions.closeCustomZapModal();
app?.actions.resetCustomZap();
batch(() => {
updateReactionsState('zappedAmount', () => -(zapOption.amount || 0));
updateReactionsState('satsZapped', (z) => z - (zapOption.amount || 0));
updateReactionsState('isZapping', () => false);
updateReactionsState('showZapAnim', () => false);
updateReactionsState('hideZapIcon', () => false);
updateReactionsState('zapped', () => note.post.noteActions.zapped);
});
// removeTopZap(zapOption);
// removeTopZapFeed(zapOption);
};
const onCancelZap = (zapOption: ZapOption) => {
const note = props.note;
if (!note) return;
app?.actions.closeCustomZapModal();
app?.actions.resetCustomZap();
batch(() => {
updateReactionsState('zappedAmount', () => -(zapOption.amount || 0));
updateReactionsState('satsZapped', (z) => z - (zapOption.amount || 0));
updateReactionsState('isZapping', () => false);
updateReactionsState('showZapAnim', () => false);
updateReactionsState('hideZapIcon', () => false);
updateReactionsState('zapped', () => note.post.noteActions.zapped);
});
// removeTopZap(zapOption);
// removeTopZapFeed(zapOption);
};
const customZapInfo: () => CustomZapInfo = () => ({
note: props.note,
onConfirm: onConfirmZap,
onSuccess: onSuccessZap,
onFail: onFailZap,
onCancel: onCancelZap,
});
return wrapper(
<>
<div class={styles.mentionedNoteHeader}>
@ -123,6 +228,15 @@ const EmbeddedNote: Component<{
margins={2}
/>
</div>
<div class={styles.footer}>
<NoteFooter
note={props.note}
state={reactionsState}
size="compact"
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
/>
</div>
</>
);
}

View File

@ -128,6 +128,10 @@
border-bottom: none;
padding-inline: 0;
z-index: var(--z-index-header);
display: flex;
justify-content: space-between;
align-items: center;
width: 608px;
}
@ -221,6 +225,52 @@
display: flex;
justify-content: center;
>button {
display: flex;
align-items: center;
justify-content: center;
width: auto;
height: 40px;
color: var(--text-primary-button);
background: var(--accent);
font-size: 14px;
font-weight: 400;
line-height: 18px;
border: none;
border-radius: 20px;
padding-block: 0;
padding-left: 4px;
margin: 0;
text-transform: lowercase;
.avatars {
display: flex;
align-items: center;
height: 40px;
.avatar {
border: solid 2px var(--text-primary-button);
border-radius: 50%;
width: 30px;
height: 30px;
transition: margin-right 0.2s;
margin-right: -9px;
}
}
.counter {
margin-left: 24px;
}
}
}
.newArticleContentNotification {
width: var(--central-content-width);
z-index: 20;
display: flex;
justify-content: center;
position: fixed;
top: 20px;
>button {
display: flex;
align-items: center;

View File

@ -27,6 +27,8 @@ const ReadsHeader: Component< {
} > = (props) => {
const reads = useReadsContext();
const account = useAccountContext();
const intl = useIntl();
let lastScrollTop = document.body.scrollTop || document.documentElement.scrollTop;
@ -68,11 +70,61 @@ const ReadsHeader: Component< {
onCleanup(() => {
window.removeEventListener('scroll', onScroll);
});
return (
<div id={props.id}>
<div class={`${styles.bigFeedSelect} ${styles.readsFeed}`}>
<ReedSelect big={true} />
<Show
when={props.hasNewPosts()}
>
<button
class={styles.newContentItem}
onClick={props.loadNewContent}
>
<div class={styles.counter}>
{intl.formatMessage(
feedNewPosts,
{
number: props.newPostCount() >= 99 ? 99 : props.newPostCount(),
},
)}
</div>
</button>
</Show>
</div>
<Show
when={props.hasNewPosts() && !account?.showNewNoteForm && !((reads?.scrollTop || 0) < 85)}
>
<div class={styles.newArticleContentNotification}>
<button
onClick={props.loadNewContent}
>
<div class={styles.avatars}>
<For each={props.newPostAuthors}>
{(user) => (
<div
class={styles.avatar}
title={userName(user)}
>
<Avatar user={user} size="xss" />
</div>
)}
</For>
</div>
<div class={styles.counter}>
{intl.formatMessage(
feedNewPosts,
{
number: props.newPostCount(),
},
)}
</div>
</button>
</div>
</Show>
</div>
);
}

View File

@ -243,8 +243,6 @@ const ArticleFooter: Component<{
newTop = -6;
}
console.log('SIZE: ', newLeft);
medZapAnimation.style.left = `${newLeft}px`;
medZapAnimation.style.top = `${newTop}px`;

View File

@ -95,6 +95,16 @@
}
}
&.compact {
grid-template-columns: 138px 138px 138px 138px auto;
.bookmarkFoot {
display: flex;
justify-content: flex-end;
max-width: 100%;
}
}
&.normal {
width: 100%;
}

View File

@ -27,11 +27,11 @@ export const lottieDuration = () => zapMD.op * 1_000 / zapMD.fr;
const NoteFooter: Component<{
note: PrimalNote,
size?: 'xwide' | 'wide' | 'normal' | 'short',
size?: 'xwide' | 'wide' | 'normal' | 'compact' | 'short',
id?: string,
state: NoteReactionsState,
updateState: SetStoreFunction<NoteReactionsState>,
customZapInfo: CustomZapInfo,
updateState?: SetStoreFunction<NoteReactionsState>,
customZapInfo?: CustomZapInfo,
large?: boolean,
onZapAnim?: (zapOption: ZapOption) => void,
}> = (props) => {
@ -66,7 +66,8 @@ const NoteFooter: Component<{
const onClickOutside = (e: MouseEvent) => {
if (
!document?.getElementById(`repost_menu_${props.note.post.id}`)?.contains(e.target as Node)
!document?.getElementById(`repost_menu_${props.note.post.id}`)?.contains(e.target as Node) &&
props.updateState
) {
props.updateState('isRepostMenuVisible', () => false);
}
@ -83,7 +84,7 @@ const NoteFooter: Component<{
const showRepostMenu = (e: MouseEvent) => {
e.preventDefault();
props.updateState('isRepostMenuVisible', () => true);
props.updateState && props.updateState('isRepostMenuVisible', () => true);
};
const doQuote = () => {
@ -91,7 +92,7 @@ const NoteFooter: Component<{
account?.actions.showGetStarted();
return;
}
props.updateState('isRepostMenuVisible', () => false);
props.updateState && props.updateState('isRepostMenuVisible', () => false);
account?.actions?.quoteNote(`nostr:${props.note.post.noteId}`);
account?.actions?.showNewNoteForm();
};
@ -113,14 +114,14 @@ const NoteFooter: Component<{
return;
}
props.updateState('isRepostMenuVisible', () => false);
props.updateState && props.updateState('isRepostMenuVisible', () => false);
const { success } = await sendRepost(props.note, account.relays, account.relaySettings);
if (success) {
batch(() => {
props.updateState('reposts', (r) => r + 1);
props.updateState('reposted', () => true);
props.updateState && props.updateState('reposts', (r) => r + 1);
props.updateState && props.updateState('reposted', () => true);
});
toast?.sendSuccess(
@ -160,8 +161,8 @@ const NoteFooter: Component<{
if (success) {
batch(() => {
props.updateState('likes', (l) => l + 1);
props.updateState('liked', () => true);
props.updateState && props.updateState('likes', (l) => l + 1);
props.updateState && props.updateState('liked', () => true);
});
}
};
@ -172,7 +173,7 @@ const NoteFooter: Component<{
if (!account?.hasPublicKey()) {
account?.actions.showGetStarted();
props.updateState('isZapping', () => false);
props.updateState && props.updateState('isZapping', () => false);
return;
}
@ -187,13 +188,13 @@ const NoteFooter: Component<{
toast?.sendWarning(
intl.formatMessage(t.zapUnavailable),
);
props.updateState('isZapping', () => false);
props.updateState && props.updateState('isZapping', () => false);
return;
}
quickZapDelay = setTimeout(() => {
app?.actions.openCustomZapModal(props.customZapInfo);
props.updateState('isZapping', () => true);
props.customZapInfo && app?.actions.openCustomZapModal(props.customZapInfo);
props.updateState && props.updateState('isZapping', () => true);
}, 500);
};
@ -219,7 +220,7 @@ const NoteFooter: Component<{
const animateZap = () => {
setTimeout(() => {
props.updateState('hideZapIcon', () => true);
props.updateState && props.updateState('hideZapIcon', () => true);
if (!medZapAnimation) {
return;
@ -243,14 +244,19 @@ const NoteFooter: Component<{
newTop = -6;
}
if (size() === 'compact') {
newLeft = 26;
newTop = -6;
}
medZapAnimation.style.left = `${newLeft}px`;
medZapAnimation.style.top = `${newTop}px`;
const onAnimDone = () => {
batch(() => {
props.updateState('showZapAnim', () => false);
props.updateState('hideZapIcon', () => false);
props.updateState('zapped', () => true);
props.updateState && props.updateState('showZapAnim', () => false);
props.updateState && props.updateState('hideZapIcon', () => false);
props.updateState && props.updateState('zapped', () => true);
});
medZapAnimation?.removeEventListener('complete', onAnimDone);
}
@ -280,9 +286,9 @@ const NoteFooter: Component<{
const emoji = settings?.defaultZap.emoji;
batch(() => {
props.updateState('isZapping', () => true);
props.updateState('satsZapped', (z) => z + amount);
props.updateState('showZapAnim', () => true);
props.updateState && props.updateState('isZapping', () => true);
props.updateState && props.updateState('satsZapped', (z) => z + amount);
props.updateState && props.updateState('showZapAnim', () => true);
});
props.onZapAnim && props.onZapAnim({ amount, message, emoji })
@ -290,10 +296,10 @@ const NoteFooter: Component<{
setTimeout(async () => {
const success = await zapNote(props.note, account.publicKey, amount, message, account.relays);
props.updateState('isZapping', () => false);
props.updateState && props.updateState('isZapping', () => false);
if (success) {
props.customZapInfo.onSuccess({
props.customZapInfo &&props.customZapInfo.onSuccess({
emoji,
amount,
message,
@ -302,7 +308,7 @@ const NoteFooter: Component<{
return;
}
props.customZapInfo.onFail({
props.customZapInfo && props.customZapInfo.onFail({
emoji,
amount,
message,

View File

@ -0,0 +1,86 @@
import { Component, createEffect, createMemo, createSignal, onMount, Show } from 'solid-js';
import { MediaVariant, PrimalNote } from '../../types/primal';
import styles from './Note.module.scss';
import { useIntl } from '@cookbook/solid-intl';
import { note as t } from '../../translations';
import { hookForDev } from '../../lib/devTools';
import MentionedUserLink from './MentionedUserLink/MentionedUserLink';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
// @ts-ignore
import PhotoSwipeDynamicCaption from 'photoswipe-dynamic-caption-plugin';
import NoteImage from '../NoteImage/NoteImage';
// @ts-ignore Bad types in nostr-tools
import { generatePrivateKey } from 'nostr-tools';
import { imageRegexG } from '../../constants';
import { useMediaContext } from '../../contexts/MediaContext';
import { createStore } from 'solid-js/store';
const NoteGallery: Component<{
note: PrimalNote,
id?: string,
}> = (props) => {
const intl = useIntl();
const media = useMediaContext();
const id = generatePrivateKey();
const lightbox = new PhotoSwipeLightbox({
gallery: `#galleryimage_${id}`,
children: `a.image_${props.note.post.noteId}`,
showHideAnimationType: 'zoom',
initialZoomLevel: 'fit',
secondaryZoomLevel: 2,
maxZoomLevel: 3,
pswpModule: () => import('photoswipe'),
});
const [store, setStore] = createStore<{ url: string, image: MediaVariant | undefined}>({
url: '',
image: undefined,
});
onMount(() => {
const urls = props.note.content.matchAll(imageRegexG);
let result = urls.next();
let images: string[] = [];
while (!result.done) {
images.push(result.value[0]);
result = urls.next();
}
let url = images[0];
let image = media?.actions.getMedia(url, 'o');
url = image?.media_url || url;
setStore(() => ({ url, image }));
const captionPlugin = new PhotoSwipeDynamicCaption(lightbox, {
// Plugins options, for example:
type: 'auto',
captionContent: '.pswp-caption-content'
});
lightbox.init();
});
return (
<div id={`galleryimage_${id}`} data-note={props.note.id} data-url={store.url}>
<NoteImage
class={`noteimage image_${props.note.post.noteId} cell_${1}`}
src={store.url}
media={store.image}
width={210}
shortHeight={true}
plainBorder={true}
caption={props.note.content.replace(imageRegexG, '')}
/>
</div>
)
}
export default hookForDev(NoteGallery);

View File

@ -16,6 +16,7 @@ const NoteImage: Component<{
onImageLoaded?: (url: string | undefined) => void,
shortHeight?: boolean,
plainBorder?: boolean,
caption?: string,
}> = (props) => {
const imgId = generatePrivateKey();
@ -142,6 +143,7 @@ const NoteImage: Component<{
onerror={onError}
width={willBeTooBig() ? undefined : `${props.width || 524}px`}
/>
<div class="pswp-caption-content">{props.caption}</div>
</a>
</Show>
);

View File

@ -53,6 +53,7 @@ import PhotoSwipeLightbox from 'photoswipe/lightbox';
import Lnbc from '../Lnbc/Lnbc';
import { subscribeTo } from '../../sockets';
import { APP_ID } from '../../App';
import { logError } from '../../lib/logger';
const groupGridLimit = 7;
@ -1009,6 +1010,7 @@ const ParsedNote: Component<{
}
} catch (e) {
logError('ERROR rendering note mention', e);
setWordsDisplayed(w => w + 1);
link = <span class={styles.error}>{token}</span>;
}

View File

@ -48,8 +48,8 @@
align-items: center;
.statNumber {
font-weight: 400;
font-size: 24px;
line-height: 24px;
font-size: 20px;
line-height: 20px;
color: var(--text-primary);
}
@ -281,3 +281,25 @@
max-width: 80% !important;
}
}
// .galleryGrid {
// display: grid;
// grid-template-columns: 210px 210px 210px;
// grid-column-gap: 5px;
// }
.galleryGrid {
display: flex;
flex-wrap: wrap;
width: 640px;
.galleryColumn {
flex: 33%;
max-width: 33%;
padding: 0 4px;
}
// >div {
// flex-grow: 1;
// }
}

View File

@ -1,10 +1,12 @@
import { useIntl } from "@cookbook/solid-intl";
import { Tabs } from "@kobalte/core";
import { A } from "@solidjs/router";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import { Component, createEffect, createSignal, For, Match, onMount, Show, Switch } from "solid-js";
import { createStore, unwrap } from "solid-js/store";
import { profileContactListPage } from "../../constants";
import { imageRegex, imageRegexG, profileContactListPage } from "../../constants";
import { useAccountContext } from "../../contexts/AccountContext";
import { useMediaContext } from "../../contexts/MediaContext";
import { useProfileContext } from "../../contexts/ProfileContext";
import { date } from "../../lib/dates";
import { hookForDev } from "../../lib/devTools";
@ -18,10 +20,14 @@ import Avatar from "../Avatar/Avatar";
import ButtonCopy from "../Buttons/ButtonCopy";
import Loader from "../Loader/Loader";
import Note from "../Note/Note";
import NoteImage from "../NoteImage/NoteImage";
import Paginator from "../Paginator/Paginator";
import ProfileContact from "../ProfileContact/ProfileContact";
// @ts-ignore Bad types in nostr-tools
import { generatePrivateKey } from 'nostr-tools';
import styles from "./ProfileTabs.module.scss";
import NoteGallery from "../Note/NoteGallery";
const ProfileTabs: Component<{
@ -32,6 +38,7 @@ const ProfileTabs: Component<{
const intl = useIntl();
const profile = useProfileContext();
const account = useAccountContext();
const media = useMediaContext();
const [currentTab, setCurrentTab] = createSignal<string>('notes');
@ -143,6 +150,9 @@ const ProfileTabs: Component<{
case 'replies':
profile.replies.length === 0 && profile.actions.fetchReplies(profile.profileKey);
break;
case 'gallery':
profile.gallery.length === 0 && profile.actions.fetchGallery(profile.profileKey);
break;
case 'follows':
profile.contacts.length === 0 && profile.actions.fetchContactList(profile.profileKey);
break;
@ -158,6 +168,13 @@ const ProfileTabs: Component<{
}
};
const galleryImages = () => {
return profile?.gallery.filter(note => {
const test = (imageRegex).test(note.content);
return test;
});
};
return (
<Show
when={profile && profile.fetchedUserStats}
@ -177,6 +194,7 @@ const ProfileTabs: Component<{
</div>
</Tabs.Trigger>
</Show>
<Tabs.Trigger class={styles.profileTab} value="notes">
<div class={styles.stat}>
<div class={styles.statNumber}>
@ -199,6 +217,14 @@ const ProfileTabs: Component<{
</div>
</Tabs.Trigger>
<Tabs.Trigger class={styles.profileTab} value="gallery">
<div class={styles.stat}>
<div class={styles.statName}>
{intl.formatMessage(t.stats.gallery)}
</div>
</div>
</Tabs.Trigger>
<Tabs.Trigger class={styles.profileTab} value="zaps">
<div class={styles.stat}>
<div class={styles.statNumber}>
@ -415,6 +441,85 @@ const ProfileTabs: Component<{
</div>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value="gallery">
<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.gallery.length === 0 && !profile.isFetchingGallery}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.noReplies,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
</div>
</Match>
<Match when={profile && profile.gallery.length > 0}>
<div class={styles.galleryGrid}>
<div class={styles.galleryColumn}>
<For each={galleryImages()}>
{(note, index) => (
<Show when={index()%3 === 1}>
<NoteGallery note={note} />
</Show>
)}
</For>
</div>
<div class={styles.galleryColumn}>
<For each={galleryImages()}>
{(note, index) => (
<Show when={index()%3 === 2}>
<NoteGallery note={note} />
</Show>
)}
</For>
</div>
<div class={styles.galleryColumn}>
<For each={galleryImages()}>
{(note, index) => (
<Show when={index()%3 === 0}>
<NoteGallery note={note} />
</Show>
)}
</For>
</div>
</div>
<Paginator
loadNextPage={() => {
profile?.actions.fetchNextGalleryPage();
}}
/>
</Match>
</Switch>
</div>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value="follows">
<div class={styles.profileNotes}>
<Show

View File

@ -267,6 +267,7 @@ const ReactionsModal: Component<{
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
};
const unsub = subscribeTo(subId, (type,_, content) => {
@ -355,7 +356,7 @@ const ReactionsModal: Component<{
});
setIsFetching(() => true);
getEventQuotes(props.noteId, subId, offset);
getEventQuotes(props.noteId, subId, offset, account?.publicKey);
};
const getQuoteCount = () => {

View File

@ -278,6 +278,8 @@ export const profileRegexG = /nostr:((npub|nprofile)1\w+)\b/g;
export const addrRegex = /nostr:((naddr)1\w+)\b/;
export const addrRegexG = /nostr:((naddr)1\w+)\b/g;
export const editMentionRegex = /(?:\s|^)@\`(.*?)\`/ig;
export const imageRegex = /(http(s?):)([/|.|\w|\s|-])*\.(?:png|jpg|jpeg|webp|gif|format=png)/;
export const imageRegexG = /(http(s?):)([/|.|\w|\s|-])*\.(?:png|jpg|jpeg|webp|gif|format=png)/g;
export const specialCharsRegex = /[^A-Za-z0-9]/;
export const hashtagCharsRegex = /[^A-Za-z0-9\-\_]/;

View File

@ -53,7 +53,7 @@ import { setLinkPreviews } from "../lib/notes";
import { subscribeTo } from "../sockets";
import { parseBolt11 } from "../utils";
import { readRecomendedUsers, saveRecomendedUsers } from "../lib/localStore";
import { fetchUserArticles } from "../handleNotes";
import { fetchUserArticles, fetchUserGallery } from "../handleNotes";
export type UserStats = {
pubkey: string,
@ -78,6 +78,7 @@ export type ProfileContextStore = {
notes: PrimalNote[],
replies: PrimalNote[],
zaps: PrimalZap[],
gallery: PrimalNote[],
zapListOffset: number,
lastZap: PrimalZap | undefined,
future: {
@ -91,12 +92,14 @@ export type ProfileContextStore = {
isFetching: boolean,
isProfileFetched: boolean,
isFetchingReplies: boolean,
isFetchingGallery: boolean,
page: FeedPage,
repliesPage: FeedPage,
reposts: Record<string, string> | undefined,
lastNote: PrimalNote | undefined,
lastArticle: PrimalArticle | undefined,
lastReply: PrimalNote | undefined,
lastGallery: PrimalNote | undefined,
following: string[],
sidebar: FeedPage & { notes: PrimalNote[] },
filterReason: { action: 'block' | 'allow', pubkey?: string, group?: string } | null,
@ -126,6 +129,9 @@ export type ProfileContextStore = {
fetchArticles: (noteId: string | undefined, until?: number) => void,
fetchNextArticlesPage: () => void,
clearArticles: () => void,
fetchGallery: (noteId: string | undefined, until?: number) => void,
fetchNextGalleryPage: () => void,
clearGallery: () => void,
updatePage: (content: NostrEventContent) => void,
savePage: (page: FeedPage) => void,
updateRepliesPage: (content: NostrEventContent) => void,
@ -167,11 +173,13 @@ export const initialData = {
articles: [],
notes: [],
replies: [],
gallery: [],
isFetching: false,
isProfileFetched: false,
isFetchingReplies: false,
isProfileFollowing: false,
isFetchingZaps: false,
isFetchingGallery: false,
page: { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {}, topZaps: {} },
repliesPage: { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {}, topZaps: {} },
reposts: {},
@ -182,6 +190,7 @@ export const initialData = {
lastArticle: undefined,
lastReply: undefined,
lastZap: undefined,
lastGallery: undefined,
following: [],
filterReason: null,
contacts: [],
@ -509,6 +518,45 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
getUserFeed(account?.publicKey, pubkey, `profile_replies_${APP_ID}`, 'replies', until, limit);
}
const fetchGallery = async (pubkey: string | undefined, until = 0, limit = 20) => {
if (!pubkey) {
return;
}
updateStore('isFetchingGallery', () => true);
let gallery = await fetchUserGallery(account?.publicKey, pubkey, 'user_media_thumbnails', `profile_gallery_${APP_ID}`, until, limit);
updateStore('gallery', (arts) => [ ...arts, ...gallery]);
updateStore('isFetchingGallery', () => false);
}
const fetchNextGalleryPage = () => {
const lastNote = store.gallery[store.gallery.length - 1];
if (!lastNote) {
return;
}
updateStore('lastGallery', () => ({ ...lastNote }));
const criteria = paginationPlan('latest');
const noteData: Record<string, any> = lastNote.repost ?
lastNote.repost.note :
lastNote.post;
const until = noteData[criteria];
if (until > 0 && store.profileKey) {
fetchGallery(store.profileKey, until);
}
};
const clearGallery = () => {
updateStore('gallery', () => []);
updateStore('lastGallery', () => undefined);
};
const clearNotes = () => {
updateStore('page', () => ({ messages: [], users: {}, postStats: {}, noteActions: {} }));
updateStore('notes', () => []);
@ -1411,6 +1459,9 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
fetchArticles,
fetchNextArticlesPage,
clearArticles,
fetchGallery,
fetchNextGalleryPage,
clearGallery,
updatePage,
savePage,
saveReplies,

View File

@ -1,6 +1,6 @@
import { nip19 } from "nostr-tools";
import { Kind } from "./constants";
import { getEvents, getUserArticleFeed } from "./lib/feed";
import { getEvents, getUserArticleFeed, getUserFeed } from "./lib/feed";
import { decodeIdentifier, hexToNpub } from "./lib/keys";
import { getParametrizedEvents, setLinkPreviews } from "./lib/notes";
import { getUserProfileInfo } from "./lib/profile";
@ -781,3 +781,188 @@ export const fetchUserProfile = (userPubkey: string | undefined, pubkey: string
};
});
}
export const fetchUserGallery = (userPubkey: string | undefined, pubkey: string | undefined, type: 'authored' | 'replies' | 'bookmarks' | 'user_media_thumbnails', subId: string, until = 0, limit = 10) => {
return new Promise<PrimalNote[]>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let page: FeedPage = {
users: {},
messages: [],
postStats: {},
mentions: {},
noteActions: {},
relayHints: {},
topZaps: {},
since: 0,
until: 0,
wordCount: {},
}
let lastNote: PrimalNote | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
const notes = convertToNotes(page, page.topZaps);
resolve(notes);
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getUserFeed(userPubkey, pubkey, subId, type, until, limit);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
page.users[user.pubkey] = { ...user };
return;
}
if ([Kind.Text, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (lastNote?.noteId !== nip19.noteEncode(message.id)) {
page.messages.push({...message});
}
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
page.postStats[stat.event_id] = { ...stat };
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
if (!page.mentions) {
page.mentions = {};
}
page.mentions[mention.id] = { ...mention };
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
page.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;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
page.relayHints = { ...page.relayHints, ...hints };
return;
}
if (content?.kind === Kind.Zap) {
const zapTag = content.tags.find(t => t[0] === 'description');
if (!zapTag) return;
const zapInfo = JSON.parse(zapTag[1] || '{}');
let amount = '0';
let bolt11Tag = content?.tags?.find(t => t[0] === 'bolt11');
if (bolt11Tag) {
try {
amount = `${parseBolt11(bolt11Tag[1]) || 0}`;
} catch (e) {
const amountTag = zapInfo.tags.find((t: string[]) => t[0] === 'amount');
amount = amountTag ? amountTag[1] : '0';
}
}
const eventId = (zapInfo.tags.find((t: string[]) => t[0] === 'e') || [])[1];
const zap: TopZap = {
id: zapInfo.id,
amount: parseInt(amount || '0'),
pubkey: zapInfo.pubkey,
message: zapInfo.content,
eventId,
};
if (page.topZaps[eventId] === undefined) {
page.topZaps[eventId] = [{ ...zap }];
return;
}
if (page.topZaps[eventId].find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...page.topZaps[eventId], { ...zap }].sort((a, b) => b.amount - a.amount);
page.topZaps[eventId] = [ ...newZaps ];
return;
}
if (content.kind === Kind.WordCount) {
const count = JSON.parse(content.content) as { event_id: string, words: number };
if (!page.wordCount) {
page.wordCount = {};
}
page.wordCount[count.event_id] = count.words
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}
};
});
};

View File

@ -437,3 +437,57 @@ body::after{
button:focus {
box-shadow: none;
}
// PhotSwipe CSS
.pswp__dynamic-caption {
color: var(--text-primary);
position: absolute;
width: 100%;
left: 0;
top: 0;
transition: opacity 120ms linear !important; /* override default */
}
.pswp-caption-content {
display: none;
}
.pswp__dynamic-caption a {
color: var(--text-primary);
}
.pswp__dynamic-caption--faded {
opacity: 0 !important;
}
.pswp__dynamic-caption--aside {
width: auto;
max-width: 300px;
padding: 20px 15px 20px 20px;
margin-top: 70px;
}
.pswp__dynamic-caption--below {
width: auto;
max-width: 700px;
padding: 15px 0 0;
}
.pswp__dynamic-caption--on-hor-edge {
padding-left: 15px;
padding-right: 15px;
}
.pswp__dynamic-caption--mobile {
width: 100%;
background: rgba(0,0,0,0.5);
padding: 10px 15px;
right: 0;
bottom: 0;
/* override styles that were set via JS.
as they interfere with size measurement */
top: auto !important;
left: 0 !important;
}

View File

@ -118,7 +118,7 @@ export const getEvents = (user_pubkey: string | undefined, eventIds: string[], s
};
export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, notes: 'authored' | 'replies' | 'bookmarks', until = 0, limit = 20, offset = 0) => {
export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, notes: 'authored' | 'replies' | 'bookmarks' | 'user_media_thumbnails', until = 0, limit = 20, offset = 0) => {
if (!pubkey) {
return;
}
@ -126,7 +126,7 @@ export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | un
let payload: {
pubkey: string,
limit: number,
notes: 'authored' | 'replies' | 'bookmarks',
notes: 'authored' | 'replies' | 'bookmarks' | 'user_media_thumbnails',
user_pubkey?: string,
until?: number,
offset?: number,

View File

@ -6,7 +6,7 @@ import LinkPreview from "../components/LinkPreview/LinkPreview";
import { addrRegex, appleMusicRegex, emojiRegex, hashtagRegex, interpunctionRegex, Kind, linebreakRegex, lnRegex, lnUnifiedRegex, mixCloudRegex, nostrNestsRegex, noteRegex, noteRegexLocal, profileRegex, profileRegexG, soundCloudRegex, spotifyRegex, tagMentionRegex, twitchRegex, urlRegex, urlRegexG, wavlakeRegex, youtubeRegex } from "../constants";
import { sendMessage, subscribeTo } from "../sockets";
import { EventCoordinate, MediaSize, NostrRelays, NostrRelaySignedEvent, PrimalArticle, PrimalNote, SendNoteResult } from "../types/primal";
import { npubToHex } from "./keys";
import { decodeIdentifier, npubToHex } from "./keys";
import { logError, logInfo, logWarning } from "./logger";
import { getMediaUrl as getMediaUrlDefault } from "./media";
import { signEvent } from "./nostrAPI";
@ -556,34 +556,120 @@ export const triggerImportEvents = (events: NostrRelaySignedEvent[], subId: stri
export const getEventReactions = (eventId: string, kind: number, subid: string, offset = 0) => {
const event_id = eventId.startsWith('note1') ? npubToHex(eventId) : eventId;
let event_id: string | undefined = eventId;
let pubkey: string | undefined;
let identifier: string | undefined;
if (eventId.startsWith('note1')) {
event_id = npubToHex(eventId);
}
if (eventId.startsWith('naddr')) {
const decode = decodeIdentifier(event_id);
pubkey = decode.data.pubkey;
identifier = decode.data.identifier;
event_id = undefined;
}
let payload = {
kind,
limit: 20,
offset,
};
if (event_id) {
// @ts-ignore
payload.event_id = event_id;
} else {
// @ts-ignore
payload.pubkey = pubkey;
// @ts-ignore
payload.identifier = identifier;
}
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["event_actions", { event_id, kind, limit: 20, offset }]},
{cache: ["event_actions", { ...payload }]},
]));
};
export const getEventQuotes = (eventId: string, subid: string, offset = 0) => {
const event_id = eventId.startsWith('note1') ? npubToHex(eventId) : eventId;
export const getEventQuotes = (eventId: string, subid: string, offset = 0, user_pubkey?: string | undefined) => {
let event_id: string | undefined = eventId;
let pubkey: string | undefined;
let identifier: string | undefined;
if (eventId.startsWith('note1')) {
event_id = npubToHex(eventId);
}
if (eventId.startsWith('naddr')) {
const decode = decodeIdentifier(event_id);
pubkey = decode.data.pubkey;
identifier = decode.data.identifier;
event_id = undefined;
}
let payload = {
imit: 20,
offset,
};
if (event_id) {
// @ts-ignore
payload.event_id = event_id;
} else {
// @ts-ignore
payload.pubkey = pubkey;
// @ts-ignore
payload.identifier = identifier;
}
if (user_pubkey) {
// @ts-ignore
payload.user_pubkey = user_pubkey;
}
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["note_mentions", { event_id, limit: 20, offset }]},
{cache: ["note_mentions", { ...payload }]},
]));
};
export const getEventZaps = (eventId: string, user_pubkey: string | undefined, subid: string, limit: number, offset = 0) => {
const event_id = eventId.startsWith('note1') ? npubToHex(eventId) : eventId;
let event_id: string | undefined = eventId;
let pubkey: string | undefined;
let identifier: string | undefined;
if (eventId.startsWith('note1')) {
event_id = npubToHex(eventId);
}
if (eventId.startsWith('naddr')) {
const decode = decodeIdentifier(event_id);
pubkey = decode.data.pubkey;
identifier = decode.data.identifier;
event_id = undefined;
}
let payload = {
event_id,
limit,
offset
};
if (event_id) {
// @ts-ignore
payload.event_id = event_id;
} else {
// @ts-ignore
payload.pubkey = pubkey;
// @ts-ignore
payload.identifier = identifier;
}
if (user_pubkey) {
// @ts-ignore
payload.user_pubkey = user_pubkey;

View File

@ -684,7 +684,7 @@ const Longform: Component< { naddr: string } > = (props) => {
const openReactionModal = (openOn = 'likes') => {
if (!store.article) return;
app?.actions.openReactionModal(store.article.id, {
app?.actions.openReactionModal(store.article.naddr, {
likes: reactionsState.likes,
zaps: reactionsState.zapCount,
reposts: reactionsState.reposts,

View File

@ -133,6 +133,7 @@ const Profile: Component = () => {
profile?.actions.clearContacts();
profile?.actions.clearZaps();
profile?.actions.clearFilterReason();
profile?.actions.clearGallery();
setHasTiers(() => false);
}

View File

@ -170,10 +170,10 @@ const Home: Component = () => {
when={params.topic}
fallback={
<ReadsHeader
hasNewPosts={() => {}}
loadNewContent={() => {}}
newPostCount={() => {}}
newPostAuthors={[]}
hasNewPosts={hasNewPosts}
loadNewContent={loadNewContent}
newPostCount={newPostCount}
newPostAuthors={newPostAuthors}
/>
}
>

View File

@ -282,7 +282,7 @@ export const convertToNotes: ConvertToNotes = (page, topZaps) => {
if (mentionIds.length > 0) {
for (let i = 0;i<mentionIds.length;i++) {
const id = mentionIds[i];
let id = mentionIds[i];
const m = mentions && mentions[id];
if (!m) {
@ -296,11 +296,36 @@ export const convertToNotes: ConvertToNotes = (page, topZaps) => {
}
}
const mentionStat = page.postStats[id];
const noteActions = (page.noteActions && page.noteActions[id]) ?? {
event_id: id,
liked: false,
replied: false,
reposted: false,
zapped: false,
};
mentionedNotes[id] = {
// @ts-ignore TODO: Investigate this typing
post: { ...m, noteId: nip19.noteEncode(m.id) },
post: {
...m,
noteId: nip19.noteEncode(m.id),
likes: mentionStat?.likes || 0,
mentions: mentionStat?.mentions || 0,
reposts: mentionStat?.reposts || 0,
replies: mentionStat?.replies || 0,
zaps: mentionStat?.zaps || 0,
score: mentionStat?.score || 0,
score24h: mentionStat?.score24h || 0,
satszapped: mentionStat?.satszapped || 0,
noteActions,
},
user: convertToUser(page.users[m.pubkey] || emptyUser(m.pubkey)),
mentionedUsers,
pubkey: m.pubkey,
id: m.id,
noteId: nip19.noteEncode(m.id),
};
}
}
@ -432,11 +457,36 @@ export const convertToArticles: ConvertToArticles = (page, topZaps) => {
}
}
const mentionStat = page.postStats[id];
const noteActions = (page.noteActions && page.noteActions[id]) ?? {
event_id: id,
liked: false,
replied: false,
reposted: false,
zapped: false,
};
mentionedNotes[id] = {
// @ts-ignore TODO: Investigate this typing
post: { ...m, noteId: nip19.noteEncode(m.id) },
post: {
...m,
noteId: nip19.noteEncode(m.id),
likes: mentionStat?.likes || 0,
mentions: mentionStat?.mentions || 0,
reposts: mentionStat?.reposts || 0,
replies: mentionStat?.replies || 0,
zaps: mentionStat?.zaps || 0,
score: mentionStat?.score || 0,
score24h: mentionStat?.score24h || 0,
satszapped: mentionStat?.satszapped || 0,
noteActions,
},
user: convertToUser(page.users[m.pubkey] || emptyUser(m.pubkey)),
mentionedUsers,
pubkey: m.pubkey,
id: m.id,
noteId: nip19.noteEncode(m.id),
};
}
}

View File

@ -1168,6 +1168,11 @@ export const profile = {
description: 'Label indicating when the profile joined Nostr (oldest event)',
},
stats: {
gallery: {
id: 'profile.gallery',
defaultMessage: 'Gallery',
description: 'Label for gallery profile stat',
},
follow: {
id: 'profile.followStats',
defaultMessage: 'Following',