mirror of
https://github.com/PrimalHQ/primal-web-app.git
synced 2024-10-01 17:31:13 +00:00
Add top zaps to thread's primary note
This commit is contained in:
parent
87d1241e85
commit
c7e066121b
@ -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;
|
||||||
|
@ -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':
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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':
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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
10
src/types/primal.d.ts
vendored
@ -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",
|
||||||
|
Loading…
Reference in New Issue
Block a user