Add top zaps to thread's primary note

This commit is contained in:
Bojan Mojsilovic 2024-04-16 17:13:17 +02:00
parent 87d1241e85
commit c7e066121b
9 changed files with 253 additions and 9 deletions

View File

@ -34,6 +34,23 @@
border-radius: 50%; border-radius: 50%;
} }
.microAvatar {
@include avatar;
width: 22px;
height: 22px;
.missingBack {
width: 22px;
height: 22px;
}
.iconBackground {
@include iconBackground;
bottom: -6px;
right: -6px;
}
}
.xxsAvatar { .xxsAvatar {
@include avatar; @include avatar;
width: 28px; width: 28px;
@ -217,6 +234,13 @@
mask-size: contain; mask-size: contain;
} }
.microMissing {
@include missing;
width: 22px;
height: 22px;
font-size: 10px;
}
.xxsMissing { .xxsMissing {
@include missing; @include missing;
width: 28px; width: 28px;

View File

@ -11,7 +11,7 @@ import styles from './Avatar.module.scss';
const Avatar: Component<{ const Avatar: Component<{
src?: string | undefined, src?: string | undefined,
size?: "xxs" | "xss" | "xs" | "vvs" | "vs" | "sm" | "md" | "lg" | "xl" | "xxl", size?: "micro" | "xxs" | "xss" | "xs" | "vvs" | "vs" | "sm" | "md" | "lg" | "xl" | "xxl",
user?: PrimalUser, user?: PrimalUser,
highlightBorder?: boolean, highlightBorder?: boolean,
id?: string, id?: string,
@ -26,6 +26,7 @@ const Avatar: Component<{
const selectedSize = props.size || 'sm'; const selectedSize = props.size || 'sm';
const avatarClass = { const avatarClass = {
micro: styles.microAvatar,
xxs: styles.xxsAvatar, xxs: styles.xxsAvatar,
xss: styles.xssAvatar, xss: styles.xssAvatar,
xs: styles.xsAvatar, xs: styles.xsAvatar,
@ -39,6 +40,7 @@ const Avatar: Component<{
}; };
const missingClass = { const missingClass = {
micro: styles.microAvatar,
xxs: styles.xxsMissing, xxs: styles.xxsMissing,
xss: styles.xssMissing, xss: styles.xssMissing,
xs: styles.xsMissing, xs: styles.xsMissing,
@ -77,6 +79,7 @@ const Avatar: Component<{
let size: MediaSize = 'm'; let size: MediaSize = 'm';
switch (selectedSize) { switch (selectedSize) {
case 'micro':
case 'xxs': case 'xxs':
case 'xss': case 'xss':
case 'xs': case 'xs':

View File

@ -328,6 +328,130 @@
} }
} }
} }
.zapHighlights {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
.firstZap {
display: flex;
align-items: center;
gap: 8px;
padding-right: 10px;
border-radius: 12px;
background: var(--devider);
width: fit-content;
max-width: 100%;
text-decoration: none;
.amount {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
line-height: 14px;
}
.description {
color: var(--text-secondary-2);
font-size: 14px;
font-weight: 400;
line-height: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
background: var(--subtile-devider);
}
}
.topZaps {
display: flex;
align-self: center;
gap: 6px;
width: 100%;
.zapList {
display: flex;
align-self: center;
gap: 6px;
max-width: calc(100% - 30px);
overflow-x: scroll;
overflow-y: hidden;
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none !important;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none !important; /* IE and Edge */
scrollbar-width: none !important; /* Firefox */
.topZap {
display: flex;
align-items: center;
gap: 6px;
padding-right: 10px;
border-radius: 12px;
background: var(--devider);
width: fit-content;
text-decoration: none;
.amount {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
line-height: 14px;
}
.description {
color: var(--text-secondary-2);
font-size: 14px;
font-weight: 400;
line-height: 14px;
}
&:hover {
background: var(--subtile-devider);
}
}
}
.moreZaps {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--devider);
width: 24px;
height: 24px;
padding: 0;
margin: 0;
border: none;
outline: none;
.contextIcon {
width: 16px;
height: 14px;
background-color: var(--text-secondary-2);
-webkit-mask: url(../../assets/icons/context.svg) no-repeat 0 / 100%;
mask: url(../../assets/icons/context.svg) no-repeat 0 / 100%;
}
&:hover {
.contextIcon {
background-color: var(--text-primary);
}
}
}
}
}
} }
} }

View File

@ -1,11 +1,11 @@
import { A } from '@solidjs/router'; import { A } from '@solidjs/router';
import { batch, Component, Match, Show, Switch } from 'solid-js'; import { batch, Component, For, Match, Show, Switch } from 'solid-js';
import { PrimalNote, ZapOption } from '../../types/primal'; import { PrimalNote, ZapOption } from '../../types/primal';
import ParsedNote from '../ParsedNote/ParsedNote'; import ParsedNote from '../ParsedNote/ParsedNote';
import NoteFooter from './NoteFooter/NoteFooter'; import NoteFooter from './NoteFooter/NoteFooter';
import styles from './Note.module.scss'; import styles from './Note.module.scss';
import { useThreadContext } from '../../contexts/ThreadContext'; import { TopZap, useThreadContext } from '../../contexts/ThreadContext';
import { useIntl } from '@cookbook/solid-intl'; import { useIntl } from '@cookbook/solid-intl';
import { hookForDev } from '../../lib/devTools'; import { hookForDev } from '../../lib/devTools';
import Avatar from '../Avatar/Avatar'; import Avatar from '../Avatar/Avatar';
@ -13,10 +13,11 @@ import NoteAuthorInfo from './NoteAuthorInfo';
import NoteRepostHeader from './NoteRepostHeader'; import NoteRepostHeader from './NoteRepostHeader';
import NoteReplyToHeader from './NoteReplyToHeader'; import NoteReplyToHeader from './NoteReplyToHeader';
import NoteHeader from './NoteHeader/NoteHeader'; import NoteHeader from './NoteHeader/NoteHeader';
import { createStore } from 'solid-js/store'; import { createStore, unwrap } from 'solid-js/store';
import { CustomZapInfo, useAppContext } from '../../contexts/AppContext'; import { CustomZapInfo, useAppContext } from '../../contexts/AppContext';
import NoteContextTrigger from './NoteContextTrigger'; import NoteContextTrigger from './NoteContextTrigger';
import { date, longDate, veryLongDate } from '../../lib/dates'; import { date, longDate, veryLongDate } from '../../lib/dates';
import { hexToNpub } from '../../lib/keys';
export type NoteFooterState = { export type NoteFooterState = {
likes: number, likes: number,
@ -129,12 +130,13 @@ const Note: Component<{
onCancel: onCancelZap, onCancel: onCancelZap,
}; };
const openReationModal = () => { const openReactionModal = (openOn = 'likes') => {
app?.actions.openReactionModal(props.note.post.id, { app?.actions.openReactionModal(props.note.post.id, {
likes: footerState.likes, likes: footerState.likes,
zaps: footerState.zapCount, zaps: footerState.zapCount,
reposts: footerState.reposts, reposts: footerState.reposts,
quotes: 0, quotes: 0,
openOn,
}); });
}; };
@ -145,7 +147,7 @@ const Note: Component<{
() => { () => {
app?.actions.openCustomZapModal(customZapInfo); app?.actions.openCustomZapModal(customZapInfo);
}, },
openReationModal, openReactionModal,
); );
} }
@ -155,6 +157,16 @@ const Note: Component<{
return (likes || 0) + (zaps || 0) + (reposts || 0); return (likes || 0) + (zaps || 0) + (reposts || 0);
}; };
const firstZap = () => (threadContext?.topZaps[props.note.post.id] || [])[0];
const topZaps = () => {
return (threadContext?.topZaps[props.note.post.id] || []).slice(1, 8);
}
const zapSender = (zap: TopZap) => {
return threadContext?.users.find(u => u.pubkey === zap.sender);
}
return ( return (
<Switch> <Switch>
<Match when={noteType() === 'notification'}> <Match when={noteType() === 'notification'}>
@ -209,6 +221,43 @@ const Note: Component<{
<ParsedNote note={props.note} width={Math.min(574, window.innerWidth)} /> <ParsedNote note={props.note} width={Math.min(574, window.innerWidth)} />
</div> </div>
<div class={styles.zapHighlights}>
<Show when={firstZap()}>
<A class={styles.firstZap} href={`/p/${hexToNpub(firstZap().sender)}`}>
<Avatar user={zapSender(firstZap())} size="micro" />
<div class={styles.amount}>
{firstZap().amount_sats.toLocaleString()}
</div>
<div class={styles.description}>
</div>
</A>
</Show>
<div class={styles.topZaps}>
<div class={styles.zapList}>
<For each={topZaps()}>
{zap => (
<A class={styles.topZap} href={`/p/${hexToNpub(zap.sender)}`}>
<Avatar user={zapSender(zap)} size="micro" />
<div class={styles.amount}>
{zap.amount_sats.toLocaleString()}
</div>
</A>
)}
</For>
</div>
<Show when={topZaps().length > 0}>
<button
class={styles.moreZaps}
onClick={() => openReactionModal('zaps')}
>
<div class={styles.contextIcon}></div>
</button>
</Show>
</div>
</div>
<div <div
class={styles.time} class={styles.time}
title={date(props.note.post?.created_at).date.toLocaleString()} title={date(props.note.post?.created_at).date.toLocaleString()}
@ -218,7 +267,7 @@ const Note: Component<{
</span> </span>
<button <button
class={styles.reactSummary} class={styles.reactSummary}
onClick={openReationModal} onClick={() => openReactionModal()}
> >
<span class={styles.number}>{reactionSum()}</span> Reactions <span class={styles.number}>{reactionSum()}</span> Reactions
</button> </button>

View File

@ -1,7 +1,7 @@
import { useIntl } from '@cookbook/solid-intl'; import { useIntl } from '@cookbook/solid-intl';
import { Tabs } from '@kobalte/core'; import { Tabs } from '@kobalte/core';
import { A } from '@solidjs/router'; import { A } from '@solidjs/router';
import { Component, createEffect, createSignal, For, Show } from 'solid-js'; import { Component, createEffect, createSignal, For, onMount, Show } from 'solid-js';
import { createStore } from 'solid-js/store'; import { createStore } from 'solid-js/store';
import { APP_ID } from '../../App'; import { APP_ID } from '../../App';
import { Kind } from '../../constants'; import { Kind } from '../../constants';
@ -43,6 +43,12 @@ const ReactionsModal: Component<{
let loadedZaps = 0; let loadedZaps = 0;
let loadedReposts = 0; let loadedReposts = 0;
createEffect(() => {
if (props.noteId && props.stats.openOn) {
setSelectedTab(props.stats.openOn)
}
});
createEffect(() => { createEffect(() => {
switch (selectedTab()) { switch (selectedTab()) {
case 'likes': case 'likes':

View File

@ -133,6 +133,7 @@ export enum Kind {
Releases = 10_000_124, Releases = 10_000_124,
ImportResponse = 10_000_127, ImportResponse = 10_000_127,
LinkMetadata = 10_000_128, LinkMetadata = 10_000_128,
EventZapInfo = 10_000_129,
FilteringReason = 10_000_131, FilteringReason = 10_000_131,
UserFollowerCounts = 10_000_133, UserFollowerCounts = 10_000_133,
SuggestedUsersByCategory = 10_000_134, SuggestedUsersByCategory = 10_000_134,

View File

@ -16,6 +16,7 @@ export type ReactionStats = {
zaps: number, zaps: number,
reposts: number, reposts: number,
quotes: number, quotes: number,
openOn: string,
}; };
export type CustomZapInfo = { export type CustomZapInfo = {
@ -91,6 +92,7 @@ const initialData: Omit<AppContextStore, 'actions'> = {
zaps: 0, zaps: 0,
reposts: 0, reposts: 0,
quotes: 0, quotes: 0,
openOn: 'likes',
}, },
showCustomZapModal: false, showCustomZapModal: false,
customZap: undefined, customZap: undefined,

View File

@ -40,6 +40,15 @@ import { APP_ID } from "../App";
import { useAccountContext } from "./AccountContext"; import { useAccountContext } from "./AccountContext";
import { setLinkPreviews } from "../lib/notes"; import { setLinkPreviews } from "../lib/notes";
export type TopZap = {
amount_sats: number,
created_at: number,
event_id: string,
receiver: string,
sender: string,
zap_receipt_id: string,
}
export type ThreadContextStore = { export type ThreadContextStore = {
primaryNote: PrimalNote | undefined, primaryNote: PrimalNote | undefined,
noteId: string; noteId: string;
@ -49,6 +58,7 @@ export type ThreadContextStore = {
page: FeedPage, page: FeedPage,
reposts: Record<string, string> | undefined, reposts: Record<string, string> | undefined,
lastNote: PrimalNote | undefined, lastNote: PrimalNote | undefined,
topZaps: Record<string, TopZap[]>,
actions: { actions: {
saveNotes: (newNotes: PrimalNote[]) => void, saveNotes: (newNotes: PrimalNote[]) => void,
clearNotes: () => void, clearNotes: () => void,
@ -78,6 +88,7 @@ export const initialData = {
}, },
reposts: {}, reposts: {},
lastNote: undefined, lastNote: undefined,
topZaps: {},
}; };
@ -211,6 +222,22 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
const hints = JSON.parse(content.content); const hints = JSON.parse(content.content);
updateStore('page', 'relayHints', (rh) => ({ ...rh, ...hints })); updateStore('page', 'relayHints', (rh) => ({ ...rh, ...hints }));
} }
if (content.kind === Kind.EventZapInfo) {
const zapInfo = JSON.parse(content.content) as TopZap;
if (store.topZaps[zapInfo.event_id] === undefined) {
updateStore('topZaps', () => ({ [zapInfo.event_id]: [{ ...zapInfo }]}));
return;
}
if (store.topZaps[zapInfo.event_id].find(i => i.zap_receipt_id === zapInfo.zap_receipt_id)) {
return;
}
updateStore('topZaps', zapInfo.event_id, (zs) => [ ...zs, { ...zapInfo }]);
}
}; };
const savePage = (page: FeedPage) => { const savePage = (page: FeedPage) => {

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

@ -214,6 +214,13 @@ export type NostrRelayHint = {
tags: string[][], tags: string[][],
}; };
export type NostrZapInfo = {
kind: Kind.EventZapInfo,
content: string,
created_at?: number,
tags: string[][],
};
export type NostrEventContent = export type NostrEventContent =
NostrNoteContent | NostrNoteContent |
NostrUserContent | NostrUserContent |
@ -243,7 +250,8 @@ export type NostrEventContent =
NostrSuggestedUsers | NostrSuggestedUsers |
PrimalUserRelays | PrimalUserRelays |
NostrBookmarks | NostrBookmarks |
NostrRelayHint; NostrRelayHint |
NostrZapInfo;
export type NostrEvent = [ export type NostrEvent = [
type: "EVENT", type: "EVENT",