Add reactions modal

This commit is contained in:
Bojan Mojsilovic 2024-03-15 11:07:10 +01:00
parent 8ab916e429
commit 109e7f40c2
8 changed files with 740 additions and 4 deletions

View File

@ -16,6 +16,8 @@ import Branding from '../Branding/Branding';
import BannerIOS, { isIOS } from '../BannerIOS/BannerIOS';
import ZapAnimation from '../ZapAnimation/ZapAnimation';
import Landing from '../../pages/Landing';
import ReactionsModal from '../ReactionsModal/ReactionsModal';
import { useAppContext } from '../../contexts/AppContext';
export const [isHome, setIsHome] = createSignal(false);
@ -26,6 +28,7 @@ const Layout: Component = () => {
const profile = useProfileContext();
const location = useLocation();
const params = useParams();
const app = useAppContext();
let container: HTMLDivElement | undefined;
@ -145,6 +148,12 @@ const Layout: Component = () => {
<div>
<Outlet />
</div>
<ReactionsModal
noteId={app?.showReactionsModal}
stats={app?.reactionStats}
onClose={() => app?.actions.closeReactionModal()}
/>
</div>
</Show>
</div>

View File

@ -26,6 +26,7 @@ import { getScreenCordinates } from '../../utils';
const NoteContextMenu: Component<{
note: PrimalNote,
openCustomZap?: () => void;
openReactions?: () => void,
id?: string,
}> = (props) => {
const account = useAccountContext();
@ -123,6 +124,14 @@ const NoteContextMenu: Component<{
}
const noteContextForEveryone: MenuItem[] = [
{
label: intl.formatMessage(tActions.noteContext.reactions),
action: () => {
props.openReactions && props.openReactions();
setContext(false);
},
icon: 'heart',
},
{
label: intl.formatMessage(tActions.noteContext.zap),
action: () => {
@ -192,7 +201,7 @@ const NoteContextMenu: Component<{
const determineOrient = () => {
const coor = getScreenCordinates(context);
const height = 380;
const height = 440;
return (coor.y || 0) + height < window.innerHeight + window.scrollY ? 'down' : 'up';
}

View File

@ -19,6 +19,8 @@ import { hookForDev } from '../../../lib/devTools';
import NoteContextMenu from '../NoteContextMenu';
import { getScreenCordinates } from '../../../utils';
import ZapAnimation from '../../ZapAnimation/ZapAnimation';
import ReactionsModal from '../../ReactionsModal/ReactionsModal';
import { useAppContext } from '../../../contexts/AppContext';
const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> = (props) => {
@ -26,6 +28,7 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
const toast = useToastContext();
const intl = useIntl();
const settings = useSettingsContext();
const app = useAppContext();
let medZapAnimation: HTMLElement | undefined;
@ -37,6 +40,7 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
const [likes, setLikes] = createSignal(props.note.post.likes);
const [reposts, setReposts] = createSignal(props.note.post.reposts);
const [replies, setReplies] = createSignal(props.note.post.replies);
const [zapCount, setZapCount] = createSignal(props.note.post.zaps);
const [zaps, setZaps] = createSignal(props.note.post.satszapped);
const [isRepostMenuVisible, setIsRepostMenuVisible] = createSignal(false);
@ -313,6 +317,7 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
createEffect(() => {
if (zappedNow()) {
setZapCount(c => c + 1);
setZaps((z) => z + zappedAmount());
setZapped(true);
setZappedNow(false);
@ -407,6 +412,14 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
openCustomZap={() => {
setIsCustomZap(true);
}}
openReactions={() => {
app?.actions.openReactionModal(props.note.post.id, {
likes: likes(),
zaps: zapCount(),
reposts: reposts(),
quotes: 0,
});
}}
/>
</div>
@ -447,7 +460,6 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
setZapped(props.note.post.noteActions.zapped);
}}
/>
</div>
)
}

View File

@ -0,0 +1,288 @@
.ReactionsModal {
position: fixed;
width: 432px;
color: var(--text-primary);
background-color: var(--background-input);
border-radius: 8px;
display: flex;
flex-direction: column;
padding: 20px;
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 24px;
.title {
display: flex;
justify-content: flex-start;
align-items: flex-start;
.caption {
font-weight: 800;
font-size: 18px;
line-height: 18px;
color: var(--text-secondary);
}
}
.close {
border: none;
outline: none;
padding: 0;
margin: 0;
box-shadow: none;
width: 20px;
height: 20px;
display: inline-block;
margin: 0px 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/close.svg) no-repeat center;
mask: url(../../assets/icons/close.svg) no-repeat center;
&:hover {
background-color: var(--text-primary);
}
}
}
.description {
color: var(--text-secondary);
font-size: 17px;
font-weight: 600;
line-height: 24px;
.tabs {
position: relative;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
padding-inline: 8px;
width: 100%;
border-radius: 0;
padding-top: 22px;
border-bottom: 1px solid var(--devider);
border-top: none;
margin-bottom: 20px;
}
.tab {
position: relative;
display: inline-block;
padding-inline: 14px;
padding-block: 2px;
border: none;
background: none;
width: fit-content;
height: 28px;
margin: 0;
margin-bottom: 12px;
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
line-height: 14px;
&:focus {
outline: none;
box-shadow: none;
}
}
.tabIndicator {
position: absolute;
height: 4px;
top: 54px;
left: 0;
border-radius: 2px 2px 0px 0px;
background: var(--accent);
transition: all 250ms;
}
.tabContent {
height: 440px;
overflow-x: hidden;
overflow-y: scroll;
@keyframes fadeIn {
from {
opacity:0;
}
to {
opacity:1;
}
}
animation: fadeIn 1s;
}
}
}
.likeItem {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 62px;
gap: 12px;
padding-inline: 10px;
border-radius: var(--border-radius-small);
&:hover {
background-color: var(--subtile-devider);
text-decoration: none;
}
.likeIcon {
width: 18px;
height: 18px;
display: inline-block;
background: var(--text-secondary);
-webkit-mask: url(../../assets/icons/heart.svg) no-repeat 0px 0 / 18px 18px;
mask: url(../../assets/icons/heart.svg) no-repeat 0px 0 / 18px 18px;
}
.userName {
display: flex;
justify-items: flex-start;
align-items: center;
.name {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
line-height: 16px;
max-width: 248px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.zapItem {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 62px;
gap: 12px;
border-radius: var(--border-radius-small);
&:hover {
background-color: var(--subtile-devider);
text-decoration: none;
}
.zapAmount {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 6px;
width: 40px;
height: 42px;
.zapIcon {
width: 18px;
height: 18px;
display: inline-block;
background: var(--text-secondary);
-webkit-mask: url(../../assets/icons/feed_zap.svg) no-repeat 0px 0 / 18px 18px;
mask: url(../../assets/icons/feed_zap.svg) no-repeat 0px 0 / 18px 18px;
}
.amount {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
line-height: 14px;
}
}
.zapInfo {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
height: 42px;
padding-block: 4px;
.userName {
display: flex;
justify-content: flex-start;
align-items: center;
.name {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
line-height: 16px;
max-width: 248px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.zapMessage {
color: var(--text-secondary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
max-width: 248px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.repostItem {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 62px;
gap: 12px;
padding-inline: 10px;
border-radius: var(--border-radius-small);
&:hover {
background-color: var(--subtile-devider);
text-decoration: none;
}
.repostIcon {
width: 18px;
height: 18px;
display: inline-block;
background: var(--text-secondary);
-webkit-mask: url(../../assets/icons/feed_repost.svg) no-repeat 0px 0 / 18px 18px;
mask: url(../../assets/icons/feed_repost.svg) no-repeat 0px 0 / 18px 18px;
}
.userName {
display: flex;
justify-items: flex-start;
align-items: center;
.name {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
line-height: 16px;
max-width: 248px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}

View File

@ -0,0 +1,361 @@
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 { createStore } from 'solid-js/store';
import { style } from 'solid-js/web';
import { APP_ID } from '../../App';
import { defaultZapOptions, Kind } from '../../constants';
import { useAccountContext } from '../../contexts/AccountContext';
import { ReactionStats } from '../../contexts/AppContext';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { hookForDev } from '../../lib/devTools';
import { hexToNpub } from '../../lib/keys';
import { getEventReactions } from '../../lib/notes';
import { truncateNumber } from '../../lib/notifications';
import { zapNote, zapProfile } from '../../lib/zap';
import { subscribeTo } from '../../sockets';
import { userName } from '../../stores/profile';
import { toastZapFail, zapCustomOption, actions as tActions, placeholders as tPlaceholders, zapCustomAmount } from '../../translations';
import { PrimalNote, PrimalUser, ZapOption } from '../../types/primal';
import { parseBolt11 } from '../../utils';
import Avatar from '../Avatar/Avatar';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import Loader from '../Loader/Loader';
import Modal from '../Modal/Modal';
import TextInput from '../TextInput/TextInput';
import { useToastContext } from '../Toaster/Toaster';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './ReactionsModal.module.scss';
const ReactionsModal: Component<{
id?: string,
noteId: string | undefined,
stats: ReactionStats;
onClose?: () => void,
}> = (props) => {
const intl = useIntl();
const [selectedTab, setSelectedTab] = createSignal('likes');
const [likeList, setLikeList] = createStore<any[]>([]);
const [zapList, setZapList] = createStore<any[]>([]);
const [repostList, setRepostList] = createStore<any[]>([]);
const [isFetching, setIsFetching] = createSignal(false)
createEffect(() => {
switch (selectedTab()) {
case 'likes':
getLikes();
break;
case 'zaps':
getZaps();
break;
case 'reposts':
getReposts();
break;
}
});
createEffect(() => {
if (!props.noteId) {
setLikeList(() => []);
setZapList(() => []);
setRepostList(() => []);
setSelectedTab(() => 'likes');
}
});
const getLikes = () => {
if (!props.noteId) return;
const subId = `nr_l_${props.noteId}_${APP_ID}`;
const users: any[] = [];
const unsub = subscribeTo(subId, (type,_, content) => {
if (type === 'EOSE') {
setLikeList(() => [...users]);
setIsFetching(() => false);
unsub();
}
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;
users.push(user);
return;
}
}
});
setIsFetching(() => true);
getEventReactions(props.noteId, Kind.Reaction, subId);
};
const getZaps = () => {
if (!props.noteId) return;
const subId = `nr_z_${props.noteId}_${APP_ID}`;
const users: Record<string, any> = {};
const zaps: any[] = [];
const unsub = subscribeTo(subId, (type,_, content) => {
if (type === 'EOSE') {
const zapData = zaps.map((zap => ({
...zap,
sender: users[zap.pubkey],
})));
setZapList(() => [ ...zapData ]);
setIsFetching(() => false);
unsub();
}
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;
users[content.pubkey] = { ...user };
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';
}
}
zaps.push({
amount,
pubkey: zapInfo.pubkey,
message: zapInfo.content,
})
return;
}
}
});
setIsFetching(() => true);
getEventReactions(props.noteId, Kind.Zap, subId);
};
const getReposts = () => {
if (!props.noteId) return;
const subId = `nr_r_${props.noteId}_${APP_ID}`;
const users: any[] = [];
const unsub = subscribeTo(subId, (type,_, content) => {
if (type === 'EOSE') {
setRepostList(() => [...users]);
setIsFetching(() => false);
unsub();
}
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;
users.push(user);
return;
}
}
});
setIsFetching(() => true);
getEventReactions(props.noteId, Kind.Repost, subId);
};
const totalCount = () => props.stats.likes + props.stats.quotes + props.stats.reposts + props.stats.zaps;
return (
<Modal
open={props.noteId !== undefined}
onClose={props.onClose}
>
<div id={props.id} class={styles.ReactionsModal}>
<div class={styles.header}>
<div class={styles.title}>
<div class={styles.caption}>
{intl.formatMessage(tActions.reactions, { count: totalCount() })}
</div>
</div>
<button class={styles.close} onClick={props.onClose}>
</button>
</div>
<div class={styles.description}>
<Tabs.Root value={selectedTab()} onChange={setSelectedTab}>
<Tabs.List class={styles.tabs}>
<Show when={props.stats.likes > 0}>
<Tabs.Trigger class={styles.tab} value={'likes'} >
Likes ({props.stats.likes})
</Tabs.Trigger>
</Show>
<Show when={props.stats.zaps > 0}>
<Tabs.Trigger class={styles.tab} value={'zaps'} >
Zaps ({props.stats.zaps})
</Tabs.Trigger>
</Show>
<Show when={props.stats.reposts > 0}>
<Tabs.Trigger class={styles.tab} value={'reposts'} >
Reposts ({props.stats.reposts})
</Tabs.Trigger>
</Show>
<Show when={props.stats.quotes > 0}>
<Tabs.Trigger class={styles.tab} value={'quotes'} >
Quotes ({props.stats.quotes})
</Tabs.Trigger>
</Show>
<Tabs.Indicator class={styles.tabIndicator} />
</Tabs.List>
<Tabs.Content class={styles.tabContent} value={'likes'}>
<Show
when={!isFetching()}
fallback={<Loader />}
>
<For
each={likeList}
>
{admirer =>
<A
href={`/p/${admirer.npub}`}
class={styles.likeItem}
onClick={props.onClose}
>
<div class={styles.likeIcon}></div>
<Avatar src={admirer.picture} size="vs" />
<div class={styles.userName}>
<div class={styles.name}>
{userName(admirer)}
</div>
<VerificationCheck user={admirer} />
</div>
</A>
}
</For>
</Show>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value={'zaps'}>
<Show
when={!isFetching()}
fallback={<Loader />}
>
<For
each={zapList}
>
{zap =>
<A
href={`/p/${zap.npub}`}
class={styles.zapItem}
onClick={props.onClose}
>
<div class={styles.zapAmount}>
<div class={styles.zapIcon}></div>
<div class={styles.amount}>{truncateNumber(zap.amount)}</div>
</div>
<Avatar src={zap.sender?.picture} size="vs" />
<div class={styles.zapInfo}>
<div class={styles.userName}>
<div class={styles.name}>
{userName(zap.sender)}
</div>
<VerificationCheck user={zap} />
</div>
<div class={styles.zapMessage}>
{zap.message}
</div>
</div>
</A>
}
</For>
</Show>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value={'reposts'}>
<Show
when={!isFetching()}
fallback={<Loader />}
>
<For
each={repostList}
>
{reposter =>
<A
href={`/p/${reposter.npub}`}
class={styles.repostItem}
onClick={props.onClose}
>
<div class={styles.repostIcon}></div>
<Avatar src={reposter.picture} size="vs" />
<div class={styles.userName}>
<div class={styles.name}>
{userName(reposter)}
</div>
<VerificationCheck user={reposter} />
</div>
</A>
}
</For>
</Show>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value={'quotes'}>
All the quotes
</Tabs.Content>
</Tabs.Root>
</div>
</div>
</Modal>
);
}
export default hookForDev(ReactionsModal);

View File

@ -8,14 +8,34 @@ import {
useContext
} from "solid-js";
export type ReactionStats = {
likes: number,
zaps: number,
reposts: number,
quotes: number,
};
export type AppContextStore = {
isInactive: boolean,
appState: 'sleep' | 'waking' | 'woke',
showReactionsModal: string | undefined,
reactionStats: ReactionStats,
actions: {
openReactionModal: (noteId: string, stats: ReactionStats) => void,
closeReactionModal: () => void,
},
}
const initialData: AppContextStore = {
const initialData: Omit<AppContextStore, 'actions'> = {
isInactive: false,
appState: 'woke',
showReactionsModal: undefined,
reactionStats: {
likes: 0,
zaps: 0,
reposts: 0,
quotes: 0,
},
};
export const AppContext = createContext<AppContextStore>();
@ -36,6 +56,21 @@ export const AppProvider = (props: { children: JSXElement }) => {
}, 3 * 60_000);
};
const openReactionModal = (noteId: string, stats: ReactionStats) => {
updateStore('reactionStats', () => ({ ...stats }));
updateStore('showReactionsModal', () => noteId);
};
const closeReactionModal = () => {
updateStore('reactionStats', () => ({
likes: 0,
zaps: 0,
reposts: 0,
quotes: 0,
}));
updateStore('showReactionsModal', () => undefined);
};
// EFFECTS --------------------------------------
onMount(() => {
@ -73,6 +108,10 @@ export const AppProvider = (props: { children: JSXElement }) => {
const [store, updateStore] = createStore<AppContextStore>({
...initialData,
actions: {
openReactionModal,
closeReactionModal,
}
});
return (

View File

@ -456,7 +456,6 @@ export const sendEvent = async (event: NostrEvent, relays: Relay[], relaySetting
}
}
export const triggerImportEvents = (events: NostrRelaySignedEvent[], subId: string, then?: () => void) => {
const unsub = subscribeTo(subId, (type) => {
@ -469,3 +468,12 @@ export const triggerImportEvents = (events: NostrRelaySignedEvent[], subId: stri
importEvents(events, subId);
};
export const getEventReactions = (eventId: string, kind: number, subid: string) => {
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["event_actions", { event_id: eventId, kind, limit: 100 }]},
]));
};

View File

@ -360,6 +360,11 @@ export const actions = {
},
},
noteContext: {
reactions: {
id: 'actions.noteContext.reactions',
defaultMessage: 'Reactions',
description: 'Label for note reactions from context menu',
},
zap: {
id: 'actions.noteContext.zapNote',
defaultMessage: 'Custom Zap',
@ -421,6 +426,11 @@ export const actions = {
defaultMessage: 'Zap',
description: 'Label for zap',
},
reactions: {
id: 'actions.reactions',
defaultMessage: 'Reactions ({count})',
description: 'Label for zap',
},
};
export const branding = {