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%;
}
.microAvatar {
@include avatar;
width: 22px;
height: 22px;
.missingBack {
width: 22px;
height: 22px;
}
.iconBackground {
@include iconBackground;
bottom: -6px;
right: -6px;
}
}
.xxsAvatar {
@include avatar;
width: 28px;
@ -217,6 +234,13 @@
mask-size: contain;
}
.microMissing {
@include missing;
width: 22px;
height: 22px;
font-size: 10px;
}
.xxsMissing {
@include missing;
width: 28px;

View File

@ -11,7 +11,7 @@ import styles from './Avatar.module.scss';
const Avatar: Component<{
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,
highlightBorder?: boolean,
id?: string,
@ -26,6 +26,7 @@ const Avatar: Component<{
const selectedSize = props.size || 'sm';
const avatarClass = {
micro: styles.microAvatar,
xxs: styles.xxsAvatar,
xss: styles.xssAvatar,
xs: styles.xsAvatar,
@ -39,6 +40,7 @@ const Avatar: Component<{
};
const missingClass = {
micro: styles.microAvatar,
xxs: styles.xxsMissing,
xss: styles.xssMissing,
xs: styles.xsMissing,
@ -77,6 +79,7 @@ const Avatar: Component<{
let size: MediaSize = 'm';
switch (selectedSize) {
case 'micro':
case 'xxs':
case 'xss':
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 { 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 ParsedNote from '../ParsedNote/ParsedNote';
import NoteFooter from './NoteFooter/NoteFooter';
import styles from './Note.module.scss';
import { useThreadContext } from '../../contexts/ThreadContext';
import { TopZap, useThreadContext } from '../../contexts/ThreadContext';
import { useIntl } from '@cookbook/solid-intl';
import { hookForDev } from '../../lib/devTools';
import Avatar from '../Avatar/Avatar';
@ -13,10 +13,11 @@ import NoteAuthorInfo from './NoteAuthorInfo';
import NoteRepostHeader from './NoteRepostHeader';
import NoteReplyToHeader from './NoteReplyToHeader';
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 NoteContextTrigger from './NoteContextTrigger';
import { date, longDate, veryLongDate } from '../../lib/dates';
import { hexToNpub } from '../../lib/keys';
export type NoteFooterState = {
likes: number,
@ -129,12 +130,13 @@ const Note: Component<{
onCancel: onCancelZap,
};
const openReationModal = () => {
const openReactionModal = (openOn = 'likes') => {
app?.actions.openReactionModal(props.note.post.id, {
likes: footerState.likes,
zaps: footerState.zapCount,
reposts: footerState.reposts,
quotes: 0,
openOn,
});
};
@ -145,7 +147,7 @@ const Note: Component<{
() => {
app?.actions.openCustomZapModal(customZapInfo);
},
openReationModal,
openReactionModal,
);
}
@ -155,6 +157,16 @@ const Note: Component<{
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 (
<Switch>
<Match when={noteType() === 'notification'}>
@ -209,6 +221,43 @@ const Note: Component<{
<ParsedNote note={props.note} width={Math.min(574, window.innerWidth)} />
</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
class={styles.time}
title={date(props.note.post?.created_at).date.toLocaleString()}
@ -218,7 +267,7 @@ const Note: Component<{
</span>
<button
class={styles.reactSummary}
onClick={openReationModal}
onClick={() => openReactionModal()}
>
<span class={styles.number}>{reactionSum()}</span> Reactions
</button>

View File

@ -1,7 +1,7 @@
import { useIntl } from '@cookbook/solid-intl';
import { Tabs } from '@kobalte/core';
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 { APP_ID } from '../../App';
import { Kind } from '../../constants';
@ -43,6 +43,12 @@ const ReactionsModal: Component<{
let loadedZaps = 0;
let loadedReposts = 0;
createEffect(() => {
if (props.noteId && props.stats.openOn) {
setSelectedTab(props.stats.openOn)
}
});
createEffect(() => {
switch (selectedTab()) {
case 'likes':

View File

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

View File

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

View File

@ -40,6 +40,15 @@ import { APP_ID } from "../App";
import { useAccountContext } from "./AccountContext";
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 = {
primaryNote: PrimalNote | undefined,
noteId: string;
@ -49,6 +58,7 @@ export type ThreadContextStore = {
page: FeedPage,
reposts: Record<string, string> | undefined,
lastNote: PrimalNote | undefined,
topZaps: Record<string, TopZap[]>,
actions: {
saveNotes: (newNotes: PrimalNote[]) => void,
clearNotes: () => void,
@ -78,6 +88,7 @@ export const initialData = {
},
reposts: {},
lastNote: undefined,
topZaps: {},
};
@ -211,6 +222,22 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
const hints = JSON.parse(content.content);
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) => {

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

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