Unify context menu and fix mute/unmute and report actions

This commit is contained in:
Bojan Mojsilovic 2024-03-18 12:57:36 +01:00
parent 1fe6b2851b
commit 29461e4e96
10 changed files with 269 additions and 122 deletions

View File

@ -19,6 +19,7 @@ import Landing from '../../pages/Landing';
import ReactionsModal from '../ReactionsModal/ReactionsModal'; import ReactionsModal from '../ReactionsModal/ReactionsModal';
import { useAppContext } from '../../contexts/AppContext'; import { useAppContext } from '../../contexts/AppContext';
import CustomZap from '../CustomZap/CustomZap'; import CustomZap from '../CustomZap/CustomZap';
import NoteContextMenu from '../Note/NoteContextMenu';
export const [isHome, setIsHome] = createSignal(false); export const [isHome, setIsHome] = createSignal(false);
@ -183,6 +184,11 @@ const Layout: Component = () => {
</div> </div>
</div> </div>
</div> </div>
<NoteContextMenu
open={app?.showNoteContextMenu}
onClose={app?.actions.closeContextMenu}
data={app?.noteContextMenuInfo}
/>
</> </>
</Show> </Show>
) )

View File

@ -185,46 +185,46 @@
} }
} }
.contextButton {
width: 16px;
height: 32px;
padding: 0;
margin: 0;
background: none;
border: none;
outline: none;
display: flex;
justify-content: center;
align-items: center;
&:focus {
outline: none;
box-shadow: 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);
}
}
}
.contextMenu { .contextMenu {
position: relative; position: absolute;
width: 16px; width: 16px;
height: 32px; height: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
text-align: center; text-align: center;
font-weight: bold; font-weight: bold;
.contextButton {
width: 16px;
height: 32px;
padding: 0;
margin: 0;
background: none;
border: none;
outline: none;
display: flex;
justify-content: center;
align-items: center;
&:focus {
outline: none;
box-shadow: 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

@ -22,11 +22,13 @@ import { reportUser } from '../../lib/profile';
import { useToastContext } from '../Toaster/Toaster'; import { useToastContext } from '../Toaster/Toaster';
import { broadcastEvent } from '../../lib/notes'; import { broadcastEvent } from '../../lib/notes';
import { getScreenCordinates } from '../../utils'; import { getScreenCordinates } from '../../utils';
import { NoteContextMenuInfo } from '../../contexts/AppContext';
import ConfirmModal from '../ConfirmModal/ConfirmModal';
const NoteContextMenu: Component<{ const NoteContextMenu: Component<{
note: PrimalNote, data: NoteContextMenuInfo,
openCustomZap?: () => void; open: boolean,
openReactions?: () => void, onClose: () => void,
id?: string, id?: string,
}> = (props) => { }> = (props) => {
const account = useAccountContext(); const account = useAccountContext();
@ -37,62 +39,95 @@ const NoteContextMenu: Component<{
const [confirmReportUser, setConfirmReportUser] = createSignal(false); const [confirmReportUser, setConfirmReportUser] = createSignal(false);
const [confirmMuteUser, setConfirmMuteUser] = createSignal(false); const [confirmMuteUser, setConfirmMuteUser] = createSignal(false);
const openContextMenu = (e: MouseEvent) => { const [orientation, setOrientation] = createSignal<'down' | 'up'>('down')
e.preventDefault();
setContext(true); const note = () => props.data?.note;
const position = () => {
return props.data?.position;
}; };
const openCustomZap = props.data?.openCustomZap || (() => {});
const openReactions = props.data?.openReactions || (() => {});
createEffect(() => {
if(!context) return;
if (!props.open) {
context.setAttribute('style',`top: -1024px; left: -1034px;`);
}
const docRect = document.documentElement.getBoundingClientRect();
const pos = {
top: (Math.floor(position()?.top || 0) - docRect.top),
left: (Math.floor(position()?.left || 0)),
}
context.setAttribute('style',`top: ${pos.top + 12}px; left: ${pos.left + 12}px;`);
const height = 440;
const orient = Math.floor(position()?.bottom || 0) + height < window.innerHeight ? 'down' : 'up';
setOrientation(() => orient);
});
const doMuteUser = () => { const doMuteUser = () => {
account?.actions.addToMuteList(props.note.post.pubkey); account?.actions.addToMuteList(note()?.post.pubkey);
props.onClose();
}; };
const doUnmuteUser = () => { const doUnmuteUser = () => {
account?.actions.removeFromMuteList(props.note.post.pubkey); account?.actions.removeFromMuteList(note()?.post.pubkey);
props.onClose();
}; };
const doReportUser = () => { const doReportUser = () => {
reportUser(props.note.user.pubkey, `report_user_${APP_ID}`, props.note.user); reportUser(note()?.user.pubkey, `report_user_${APP_ID}`, note()?.user);
setContext(false); props.onClose();
toaster?.sendSuccess(intl.formatMessage(tToast.noteAuthorReported, { name: userName(props.note.user)})); toaster?.sendSuccess(intl.formatMessage(tToast.noteAuthorReported, { name: userName(note()?.user)}));
}; };
const copyNoteLink = () => { const copyNoteLink = () => {
navigator.clipboard.writeText(`${window.location.origin}/e/${props.note.post.noteId}`); if (!props.data) return;
setContext(false); navigator.clipboard.writeText(`${window.location.origin}/e/${note().post.noteId}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.notePrimalLinkCoppied)); toaster?.sendSuccess(intl.formatMessage(tToast.notePrimalLinkCoppied));
}; };
const copyNoteText = () => { const copyNoteText = () => {
navigator.clipboard.writeText(`${props.note.post.content}`); if (!props.data) return;
setContext(false); navigator.clipboard.writeText(`${note().post.content}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.notePrimalTextCoppied)); toaster?.sendSuccess(intl.formatMessage(tToast.notePrimalTextCoppied));
}; };
const copyNoteId = () => { const copyNoteId = () => {
navigator.clipboard.writeText(`${props.note.post.noteId}`); if (!props.data) return;
setContext(false); navigator.clipboard.writeText(`${note().post.noteId}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.noteIdCoppied)); toaster?.sendSuccess(intl.formatMessage(tToast.noteIdCoppied));
}; };
const copyRawData = () => { const copyRawData = () => {
navigator.clipboard.writeText(`${JSON.stringify(props.note.msg)}`); if (!props.data) return;
setContext(false); navigator.clipboard.writeText(`${JSON.stringify(note().msg)}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.noteRawDataCoppied)); toaster?.sendSuccess(intl.formatMessage(tToast.noteRawDataCoppied));
}; };
const copyUserNpub = () => { const copyUserNpub = () => {
navigator.clipboard.writeText(`${props.note.user.npub}`); if (!props.data) return;
setContext(false); navigator.clipboard.writeText(`${note().user.npub}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.noteAuthorNpubCoppied)); toaster?.sendSuccess(intl.formatMessage(tToast.noteAuthorNpubCoppied));
}; };
const broadcastNote = async () => { const broadcastNote = async () => {
if (!account) { if (!account || !props.data) {
return; return;
} }
const { success } = await broadcastEvent(props.note.msg as NostrRelaySignedEvent, account?.relays, account?.relaySettings); const { success } = await broadcastEvent(note().msg as NostrRelaySignedEvent, account?.relays, account?.relaySettings);
setContext(false); props.onClose()
if (success) { if (success) {
toaster?.sendSuccess(intl.formatMessage(tToast.noteBroadcastSuccess)); toaster?.sendSuccess(intl.formatMessage(tToast.noteBroadcastSuccess));
@ -103,14 +138,15 @@ const NoteContextMenu: Component<{
const onClickOutside = (e: MouseEvent) => { const onClickOutside = (e: MouseEvent) => {
if ( if (
!document?.getElementById(`note_context_${props.note.post.id}`)?.contains(e.target as Node) !props.data ||
!document?.getElementById(`note_context_${note().post.id}`)?.contains(e.target as Node)
) { ) {
setContext(false); props.onClose()
} }
} }
createEffect(() => { createEffect(() => {
if (showContext()) { if (props.open) {
document.addEventListener('click', onClickOutside); document.addEventListener('click', onClickOutside);
} }
else { else {
@ -119,24 +155,24 @@ const NoteContextMenu: Component<{
}); });
const isVerifiedByPrimal = () => { const isVerifiedByPrimal = () => {
return !!props.note.user.nip05 && return props.data && !!note().user.nip05 &&
props.note.user.nip05.endsWith('primal.net'); note().user.nip05.endsWith('primal.net');
} }
const noteContextForEveryone: MenuItem[] = [ const noteContextForEveryone: MenuItem[] = [
{ {
label: intl.formatMessage(tActions.noteContext.reactions), label: intl.formatMessage(tActions.noteContext.reactions),
action: () => { action: () => {
props.openReactions && props.openReactions(); openReactions();
setContext(false); props.onClose()
}, },
icon: 'heart', icon: 'heart',
}, },
{ {
label: intl.formatMessage(tActions.noteContext.zap), label: intl.formatMessage(tActions.noteContext.zap),
action: () => { action: () => {
props.openCustomZap && props.openCustomZap(); openCustomZap();
setContext(false); props.onClose()
}, },
icon: 'feed_zap', icon: 'feed_zap',
}, },
@ -172,53 +208,65 @@ const NoteContextMenu: Component<{
}, },
]; ];
const noteContextForOtherPeople: MenuItem[] = [ const noteContextForOtherPeople: () => MenuItem[] = () => {
{ const isMuted = account?.muted.includes(note()?.user.pubkey);
label: intl.formatMessage(tActions.noteContext.muteAuthor),
action: () => {
setConfirmMuteUser(true);
setContext(false);
},
icon: 'mute_user',
warning: true,
},
{
label: intl.formatMessage(tActions.noteContext.reportAuthor),
action: () => {
setConfirmReportUser(true);
setContext(false);
},
icon: 'report',
warning: true,
},
];
const noteContext = account?.publicKey !== props.note.post.pubkey ? return [
[ ...noteContextForEveryone, ...noteContextForOtherPeople] : {
label: isMuted ? intl.formatMessage(tActions.noteContext.unmuteAuthor) : intl.formatMessage(tActions.noteContext.muteAuthor),
action: () => {
isMuted ? doUnmuteUser() : setConfirmMuteUser(true);
props.onClose()
},
icon: 'mute_user',
warning: true,
},
{
label: intl.formatMessage(tActions.noteContext.reportAuthor),
action: () => {
setConfirmReportUser(true);
props.onClose()
},
icon: 'report',
warning: true,
},
];
};
const noteContext = () => account?.publicKey !== note()?.post.pubkey ?
[ ...noteContextForEveryone, ...noteContextForOtherPeople()] :
noteContextForEveryone; noteContextForEveryone;
let context: HTMLDivElement | undefined; let context: HTMLDivElement | undefined;
const determineOrient = () => {
const coor = getScreenCordinates(context);
const height = 440;
return (coor.y || 0) + height < window.innerHeight + window.scrollY ? 'down' : 'up';
}
return ( return (
<div class={styles.contextMenu} ref={context}> <div class={styles.contextMenu} ref={context}>
<button <ConfirmModal
class={styles.contextButton} open={confirmReportUser()}
onClick={openContextMenu} description={intl.formatMessage(tActions.reportUserConfirm, { name: authorName(note()?.user) })}
> onConfirm={() => {
<div class={styles.contextIcon} ></div> doReportUser();
</button> setConfirmReportUser(false);
}}
onAbort={() => setConfirmReportUser(false)}
/>
<ConfirmModal
open={confirmMuteUser()}
description={intl.formatMessage(tActions.muteUserConfirm, { name: authorName(note()?.user) })}
onConfirm={() => {
doMuteUser();
setConfirmMuteUser(false);
}}
onAbort={() => setConfirmMuteUser(false)}
/>
<PrimalMenu <PrimalMenu
id={`note_context_${props.note.post.id}`} id={`note_context_${note()?.post.id}`}
items={noteContext} items={noteContext()}
hidden={!showContext()} hidden={!props.open}
position="note_footer" position="note_footer"
orientation={determineOrient()} orientation={orientation()}
/> />
</div> </div>
) )

View File

@ -9,6 +9,38 @@
align-items: center; align-items: center;
} }
.contextButton {
width: 48px;
height: 32px;
padding: 0;
margin: 0;
background: none;
border: none;
outline: none;
display: flex;
justify-content: center;
align-items: center;
&:focus {
outline: none;
box-shadow: 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);
}
}
}
.footer { .footer {
display: grid; display: grid;
grid-template-columns: 128px 128px 128px 128px 16px; grid-template-columns: 128px 128px 128px 128px 16px;

View File

@ -46,6 +46,7 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
const [isRepostMenuVisible, setIsRepostMenuVisible] = createSignal(false); const [isRepostMenuVisible, setIsRepostMenuVisible] = createSignal(false);
let footerDiv: HTMLDivElement | undefined; let footerDiv: HTMLDivElement | undefined;
let noteContextMenu: HTMLDivElement | undefined;
const repostMenuItems: MenuItem[] = [ const repostMenuItems: MenuItem[] = [
{ {
@ -374,7 +375,7 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
} }
return ( return (
<div id={props.id} class={`${styles.footer} ${props.wide ? styles.wide : ''}`} ref={footerDiv}> <div id={props.id} class={`${styles.footer} ${props.wide ? styles.wide : ''}`} ref={footerDiv} onClick={(e) => {e.preventDefault();}}>
<Show when={showZapAnim()}> <Show when={showZapAnim()}>
<ZapAnimation <ZapAnimation
@ -442,21 +443,32 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
</div> </div>
</button> </button>
<div class={styles.context}> <div ref={noteContextMenu} class={styles.context}>
<NoteContextMenu <button
note={props.note} class={styles.contextButton}
openCustomZap={() => { onClick={(e) => {
app?.actions.openCustomZapModal(customZapInfo); e.preventDefault();
e.stopPropagation();
app?.actions.openContextMenu(
props.note,
noteContextMenu?.getBoundingClientRect(),
() => {
app?.actions.openCustomZapModal(customZapInfo);
},
() => {
app?.actions.openReactionModal(props.note.post.id, {
likes: likes(),
zaps: zapCount(),
reposts: reposts(),
quotes: 0,
});
}
);
}} }}
openReactions={() => { >
app?.actions.openReactionModal(props.note.post.id, { <div class={styles.contextIcon} ></div>
likes: likes(), </button>
zaps: zapCount(),
reposts: reposts(),
quotes: 0,
});
}}
/>
</div> </div>
</div> </div>
) )

View File

@ -32,6 +32,8 @@ const ProfileTabs: Component<{
const profile = useProfileContext(); const profile = useProfileContext();
const account = useAccountContext(); const account = useAccountContext();
const [currentTab, setCurrentTab] = createSignal<string>('notes');
const addToAllowlist = async () => { const addToAllowlist = async () => {
const pk = profile?.profileKey; const pk = profile?.profileKey;
if (pk) { if (pk) {
@ -58,7 +60,10 @@ const ProfileTabs: Component<{
return; return;
} }
account.actions.removeFromMuteList(pk); account.actions.removeFromMuteList(pk, () => {
props.setProfile && props.setProfile(pk);
onChangeValue(currentTab());
});
}; };
const onContactAction = (remove: boolean, pubkey: string) => { const onContactAction = (remove: boolean, pubkey: string) => {
@ -125,6 +130,8 @@ const ProfileTabs: Component<{
const onChangeValue = (value: string) => { const onChangeValue = (value: string) => {
if (!profile) return; if (!profile) return;
setCurrentTab(() => value);
switch(value) { switch(value) {
case 'notes': case 'notes':
profile.notes.length === 0 && profile.actions.fetchNotes(profile.profileKey); profile.notes.length === 0 && profile.actions.fetchNotes(profile.profileKey);

View File

@ -25,6 +25,13 @@ export type CustomZapInfo = {
onCancel: (zapOption: ZapOption) => void, onCancel: (zapOption: ZapOption) => void,
}; };
export type NoteContextMenuInfo = {
note: PrimalNote,
position: DOMRect | undefined,
openCustomZap?: () => void,
openReactions?: () => void,
};
export type AppContextStore = { export type AppContextStore = {
isInactive: boolean, isInactive: boolean,
appState: 'sleep' | 'waking' | 'woke', appState: 'sleep' | 'waking' | 'woke',
@ -32,11 +39,15 @@ export type AppContextStore = {
reactionStats: ReactionStats, reactionStats: ReactionStats,
showCustomZapModal: boolean, showCustomZapModal: boolean,
customZap: CustomZapInfo | undefined, customZap: CustomZapInfo | undefined,
showNoteContextMenu: boolean,
noteContextMenuInfo: NoteContextMenuInfo | undefined,
actions: { actions: {
openReactionModal: (noteId: string, stats: ReactionStats) => void, openReactionModal: (noteId: string, stats: ReactionStats) => void,
closeReactionModal: () => void, closeReactionModal: () => void,
openCustomZapModal: (custonZapInfo: CustomZapInfo) => void, openCustomZapModal: (custonZapInfo: CustomZapInfo) => void,
closeCustomZapModal: () => void, closeCustomZapModal: () => void,
openContextMenu: (note: PrimalNote, position: DOMRect | undefined, openCustomZapModal: () => void, openReactionModal: () => void) => void,
closeContextMenu: () => void,
}, },
} }
@ -52,6 +63,8 @@ const initialData: Omit<AppContextStore, 'actions'> = {
}, },
showCustomZapModal: false, showCustomZapModal: false,
customZap: undefined, customZap: undefined,
showNoteContextMenu: false,
noteContextMenuInfo: undefined,
}; };
export const AppContext = createContext<AppContextStore>(); export const AppContext = createContext<AppContextStore>();
@ -96,6 +109,20 @@ export const AppProvider = (props: { children: JSXElement }) => {
updateStore('showCustomZapModal', () => false); updateStore('showCustomZapModal', () => false);
}; };
const openContextMenu = (note: PrimalNote, position: DOMRect | undefined, openCustomZapModal: () => void, openReactionModal: () => void) => {
updateStore('noteContextMenuInfo', reconcile({
note,
position,
openCustomZapModal,
openReactionModal,
}))
updateStore('showNoteContextMenu', () => true);
};
const closeContextMenu = () => {
updateStore('showNoteContextMenu', () => false);
};
// EFFECTS -------------------------------------- // EFFECTS --------------------------------------
onMount(() => { onMount(() => {
@ -138,6 +165,8 @@ export const AppProvider = (props: { children: JSXElement }) => {
closeReactionModal, closeReactionModal,
openCustomZapModal, openCustomZapModal,
closeCustomZapModal, closeCustomZapModal,
openContextMenu,
closeContextMenu,
} }
}); });

View File

@ -128,6 +128,7 @@ export type ProfileContextStore = {
fetchFollowerList: (pubkey: string | undefined) => void, fetchFollowerList: (pubkey: string | undefined) => void,
fetchRelayList: (pubkey: string | undefined) => void, fetchRelayList: (pubkey: string | undefined) => void,
clearContacts: () => void, clearContacts: () => void,
clearFilterReason: () => void,
removeContact: (pubkey: string) => void, removeContact: (pubkey: string) => void,
addContact: (pubkey: string, source: PrimalUser[]) => void, addContact: (pubkey: string, source: PrimalUser[]) => void,
fetchZapList: (pubkey: string | undefined) => void, fetchZapList: (pubkey: string | undefined) => void,
@ -514,6 +515,10 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
updateStore('profileStats', () => ({})); updateStore('profileStats', () => ({}));
}; };
const clearFilterReason = () => {
updateStore('filterReason', () => null);
};
const fetchNextPage = () => { const fetchNextPage = () => {
const lastNote = store.notes[store.notes.length - 1]; const lastNote = store.notes[store.notes.length - 1];
@ -1018,6 +1023,8 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
if (reason?.action === 'block') { if (reason?.action === 'block') {
updateStore('filterReason', () => ({ ...reason })); updateStore('filterReason', () => ({ ...reason }));
} else {
updateStore('filterReason', () => null);
} }
} }
@ -1229,6 +1236,7 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
fetchNextZapsPage, fetchNextZapsPage,
clearZaps, clearZaps,
resetProfile, resetProfile,
clearFilterReason,
}, },
}); });

View File

@ -121,6 +121,7 @@ const Profile: Component = () => {
profile?.actions.clearReplies(); profile?.actions.clearReplies();
profile?.actions.clearContacts(); profile?.actions.clearContacts();
profile?.actions.clearZaps(); profile?.actions.clearZaps();
profile?.actions.clearFilterReason();
} }
let keyIsDone = false let keyIsDone = false
@ -416,7 +417,6 @@ const Profile: Component = () => {
toaster?.sendSuccess(intl.formatMessage(tToast.noteAuthorReported, { name: userName(profile?.userProfile)})); toaster?.sendSuccess(intl.formatMessage(tToast.noteAuthorReported, { name: userName(profile?.userProfile)}));
}; };
const addToAllowlist = async () => { const addToAllowlist = async () => {
const pk = getHex(); const pk = getHex();
if (pk) { if (pk) {
@ -679,7 +679,7 @@ const Profile: Component = () => {
</Show> </Show>
</div> </div>
<ProfileTabs /> <ProfileTabs setProfile={setProfile} />
<ConfirmModal <ConfirmModal
open={confirmReportUser()} open={confirmReportUser()}

View File

@ -405,6 +405,11 @@ export const actions = {
defaultMessage: 'Mute user', defaultMessage: 'Mute user',
description: 'Label for muting user from context menu', description: 'Label for muting user from context menu',
}, },
unmuteAuthor: {
id: 'actions.noteContext.unmuteAuthor',
defaultMessage: 'Unmute user',
description: 'Label for unmuting user from context menu',
},
reportAuthor: { reportAuthor: {
id: 'actions.noteContext.reportAuthor', id: 'actions.noteContext.reportAuthor',
defaultMessage: 'Report user', defaultMessage: 'Report user',