Note quotes reactions

This commit is contained in:
Bojan Mojsilovic 2024-04-24 14:27:34 +02:00
parent a12bfc573d
commit 00f976ae78
17 changed files with 378 additions and 61 deletions

View File

@ -17,6 +17,10 @@
text-decoration: none !important;
}
&.altBack {
background-color: var(--background-sheet);
}
.mentionedNoteHeader {
display: flex;
justify-content: flex-start;

View File

@ -15,7 +15,13 @@ import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './EmbeddedNote.module.scss';
const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record<string, PrimalUser>, includeEmbeds?: boolean, isLast?: boolean}> = (props) => {
const EmbeddedNote: Component<{
note: PrimalNote,
mentionedUsers?: Record<string, PrimalUser>,
includeEmbeds?: boolean,
isLast?: boolean,
alternativeBackground?: boolean,
}> = (props) => {
const threadContext = useThreadContext();
const intl = useIntl();
@ -32,11 +38,20 @@ const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record<string
return trimVerification(props.note.user?.nip05);
});
const klass = () => {
let k = styles.mentionedNote;
k += ' embeddedNote';
if (props.isLast) k+= ' noBottomMargin';
if (props.alternativeBackground) k+= ` ${styles.altBack}`;
return k;
}
const wrapper = (children: JSXElement) => {
if (props.includeEmbeds) {
return (
<div
class={`${styles.mentionedNote} embeddedNote ${props.isLast ? 'noBottomMargin' : ''}`}
class={klass()}
data-event={props.note.post.id}
data-event-bech32={noteId()}
>
@ -48,7 +63,7 @@ const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record<string
return (
<A
href={`/e/${noteId()}`}
class={`${styles.mentionedNote} embeddedNote ${props.isLast ? 'noBottomMargin' : ''}`}
class={klass()}
onClick={() => navToThread()}
data-event={props.note.post.id}
data-event-bech32={noteId()}

View File

@ -23,6 +23,11 @@
border: none;
}
&.reactionNote {
background: none;
border-bottom: 1px solid var(--subtile-devider);
}
.header {
position: relative;
display: flex;

View File

@ -22,7 +22,7 @@ import { thread, zapCustomOption } from '../../translations';
import { useAccountContext } from '../../contexts/AccountContext';
import { uuidv4 } from '../../utils';
export type NoteFooterState = {
export type NoteReactionsState = {
likes: number,
liked: boolean,
reposts: number,
@ -40,6 +40,7 @@ export type NoteFooterState = {
moreZapsAvailable: boolean,
isRepostMenuVisible: boolean,
topZaps: TopZap[],
quoteCount: number,
};
const Note: Component<{
@ -47,7 +48,9 @@ const Note: Component<{
id?: string,
parent?: boolean,
shorten?: boolean,
noteType?: 'feed' | 'primary' | 'notification'
noteType?: 'feed' | 'primary' | 'notification' | 'reaction'
onClick?: () => void,
quoteCount?: number,
}> = (props) => {
const threadContext = useThreadContext();
@ -55,15 +58,22 @@ const Note: Component<{
const account = useAccountContext();
const intl = useIntl();
createEffect(() => {
if (props.quoteCount) {
updateReactionsState('quoteCount', () => props.quoteCount || 0);
}
})
const noteType = () => props.noteType || 'feed';
const repost = () => props.note.repost;
const navToThread = (note: PrimalNote) => {
props.onClick && props.onClick();
threadContext?.actions.setPrimaryNote(note);
};
const [reactionsState, updateReactionsState] = createStore<NoteFooterState>({
const [reactionsState, updateReactionsState] = createStore<NoteReactionsState>({
likes: props.note.post.likes,
liked: props.note.post.noteActions.liked,
reposts: props.note.post.reposts,
@ -81,6 +91,7 @@ const Note: Component<{
moreZapsAvailable: false,
isRepostMenuVisible: false,
topZaps: [],
quoteCount: props.quoteCount || 0,
});
let noteContextMenu: HTMLDivElement | undefined;
@ -173,7 +184,7 @@ const Note: Component<{
likes: reactionsState.likes,
zaps: reactionsState.zapCount,
reposts: reactionsState.reposts,
quotes: 0,
quotes: reactionsState.quoteCount,
openOn,
});
};
@ -190,9 +201,9 @@ const Note: Component<{
}
const reactionSum = () => {
const { likes, zapCount, reposts } = reactionsState;
const { likes, zapCount, reposts, quoteCount } = reactionsState;
return (likes || 0) + (zapCount || 0) + (reposts || 0);
return (likes || 0) + (zapCount || 0) + (reposts || 0) + (quoteCount || 0);
};
const firstZap = createMemo(() => reactionsState.topZaps[0]);
@ -420,6 +431,50 @@ const Note: Component<{
</div>
</A>
</Match>
<Match when={noteType() === 'reaction'}>
<A
id={props.id}
class={`${styles.note} ${styles.reactionNote}`}
href={`/e/${props.note?.post.noteId}`}
onClick={() => navToThread(props.note)}
data-event={props.note.post.id}
data-event-bech32={props.note.post.noteId}
draggable={false}
>
<div class={styles.content}>
<div class={styles.leftSide}>
<A href={`/p/${props.note.user.npub}`}>
<Avatar user={props.note.user} size="vs" />
</A>
<Show
when={props.parent}
>
<div class={styles.ancestorLine}></div>
</Show>
</div>
<div class={styles.rightSide}>
<NoteAuthorInfo
author={props.note.user}
time={props.note.post.created_at}
/>
<NoteReplyToHeader note={props.note} />
<div class={styles.message}>
<ParsedNote
note={props.note}
shorten={props.shorten}
width={Math.min(528, window.innerWidth - 72)}
noLightbox={true}
altEmbeds={true}
/>
</div>
</div>
</div>
</A>
</Match>
</Switch>
);
}

View File

@ -19,7 +19,7 @@ import { getScreenCordinates } from '../../../utils';
import ZapAnimation from '../../ZapAnimation/ZapAnimation';
import { CustomZapInfo, useAppContext } from '../../../contexts/AppContext';
import NoteFooterActionButton from './NoteFooterActionButton';
import { NoteFooterState } from '../Note';
import { NoteReactionsState } from '../Note';
import { SetStoreFunction } from 'solid-js/store';
import BookmarkNote from '../../BookmarkNote/BookmarkNote';
import { APP_ID } from '../../../App';
@ -30,8 +30,8 @@ const NoteFooter: Component<{
note: PrimalNote,
wide?: boolean,
id?: string,
state: NoteFooterState,
updateState: SetStoreFunction<NoteFooterState>,
state: NoteReactionsState,
updateState: SetStoreFunction<NoteReactionsState>,
customZapInfo: CustomZapInfo,
large?: boolean,
}> = (props) => {

View File

@ -132,6 +132,7 @@ const NoteImage: Component<{
data-pswp-height={zoomH()}
data-image-group={props.imageGroup}
data-cropped={true}
target="_blank"
>
<img
id={imgId}

View File

@ -103,3 +103,17 @@
margin-left: 2px;
}
}
.reactionNote {
display: flex;
width: fit-content;
align-items: center;
justify-content: center;
height: 48px;
font-weight: 700;
margin-block: 4px;
padding-inline: 12px;
border: 1px solid var(--subtile-devider);
border-radius: var(--border-radius-small);
}

View File

@ -144,6 +144,8 @@ const ParsedNote: Component<{
shorten?: boolean,
isEmbeded?: boolean,
width?: number,
noLightbox?: boolean,
altEmbeds?: boolean,
}> = (props) => {
const intl = useIntl();
@ -172,6 +174,8 @@ const ParsedNote: Component<{
});
onMount(() => {
if (props.noLightbox) return;
lightbox.init();
});
@ -927,6 +931,7 @@ const ParsedNote: Component<{
note={ment}
mentionedUsers={props.note.mentionedUsers || {}}
isLast={index === content.length-1}
alternativeBackground={props.altEmbeds}
/>
</div>;
}

View File

@ -7,7 +7,7 @@
display: flex;
flex-direction: column;
padding: 20px;
padding: 16px;
.header {
display: flex;
@ -73,7 +73,7 @@
.tab {
position: relative;
display: inline-block;
padding-inline: 14px;
padding-inline: 12px;
padding-block: 2px;
border: none;
background: none;
@ -107,13 +107,14 @@
height: 440px;
overflow-x: hidden;
overflow-y: scroll;
padding-right: 8px;
@keyframes fadeIn {
from {
opacity:0;
opacity:0;
}
to {
opacity:1;
opacity:1;
}
}

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, onMount, Show } from 'solid-js';
import { Component, createEffect, createSignal, For, Match, onMount, Show, Switch } from 'solid-js';
import { createStore } from 'solid-js/store';
import { APP_ID } from '../../App';
import { Kind } from '../../constants';
@ -9,20 +9,25 @@ import { useAccountContext } from '../../contexts/AccountContext';
import { ReactionStats } from '../../contexts/AppContext';
import { hookForDev } from '../../lib/devTools';
import { hexToNpub } from '../../lib/keys';
import { getEventQuotes, getEventReactions, getEventZaps } from '../../lib/notes';
import { getEventQuotes, getEventQuoteStats, getEventReactions, getEventZaps, setLinkPreviews } from '../../lib/notes';
import { truncateNumber2 } from '../../lib/notifications';
import { updateStore } from '../../services/StoreService';
import { subscribeTo } from '../../sockets';
import { convertToNotes } from '../../stores/note';
import { userName } from '../../stores/profile';
import { actions as tActions, placeholders as tPlaceholders, reactionsModal } from '../../translations';
import { FeedPage, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalNote, PrimalUser } from '../../types/primal';
import { parseBolt11 } from '../../utils';
import Avatar from '../Avatar/Avatar';
import Loader from '../Loader/Loader';
import Modal from '../Modal/Modal';
import Note from '../Note/Note';
import Paginator from '../Paginator/Paginator';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './ReactionsModal.module.scss';
const ReactionsModal: Component<{
id?: string,
noteId: string | undefined,
@ -38,18 +43,33 @@ const ReactionsModal: Component<{
const [likeList, setLikeList] = createStore<any[]>([]);
const [zapList, setZapList] = createStore<any[]>([]);
const [repostList, setRepostList] = createStore<any[]>([]);
// const [quotesList, setQuotesList] = createStore<any[]>([]);
const [quotesList, setQuotesList] = createStore<PrimalNote[]>([]);
const [quoteCount, setQuoteCount] = createSignal(0);
const [isFetching, setIsFetching] = createSignal(false);
let loadedLikes = 0;
let loadedZaps = 0;
let loadedReposts = 0;
// let loadedQuotes = 0;
let loadedQuotes = 0;
createEffect(() => {
const count = quoteCount();
if (count === 0 && props.stats.quotes > 0) {
setQuoteCount(props.stats.quotes);
}
})
createEffect(() => {
if (props.noteId && props.stats.openOn) {
setSelectedTab(props.stats.openOn)
setSelectedTab(props.stats.openOn);
}
});
createEffect(() => {
if (props.noteId) {
getQuoteCount();
}
});
@ -64,9 +84,9 @@ const ReactionsModal: Component<{
case 'reposts':
loadedReposts === 0 && getReposts();
break;
// case 'quotes':
// loadedQuotes === 0 && getQuotes();
// break;
case 'quotes':
loadedQuotes === 0 && getQuotes();
break;
}
});
@ -76,10 +96,13 @@ const ReactionsModal: Component<{
setZapList(() => []);
setRepostList(() => []);
setSelectedTab(() => 'likes');
setQuotesList(() => []);
setQuoteCount(() => 0);
loadedLikes = 0;
loadedZaps = 0;
loadedReposts = 0;
loadedQuotes = 0;
}
});
@ -203,6 +226,7 @@ const ReactionsModal: Component<{
const users: any[] = [];
const unsub = subscribeTo(subId, (type,_, content) => {
if (type === 'EOSE') {
setRepostList((reposts) => [...reposts, ...users]);
loadedReposts = repostList.length;
@ -232,44 +256,128 @@ const ReactionsModal: Component<{
getEventReactions(props.noteId, Kind.Repost, subId, offset);
};
// const getQuotes = (offset = 0) => {
// if (!props.noteId) return;
const getQuotes = (offset = 0) => {
if (!props.noteId) return;
// const subId = `nr_q_${props.noteId}_${APP_ID}`;
const subId = `nr_q_${props.noteId}_${APP_ID}`;
// const users: any[] = [];
let page: FeedPage = {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
};
// const unsub = subscribeTo(subId, (type,_, content) => {
// if (type === 'EOSE') {
// setQuotesList((reposts) => [...reposts, ...users]);
// loadedQuotes = quotesList.length;
// setIsFetching(() => false);
// unsub();
// }
const unsub = subscribeTo(subId, (type,_, content) => {
if (type === 'EOSE') {
const pageNotes = convertToNotes(page);
// if (type === 'EVENT') {
// if (content?.kind === Kind.Metadata) {
// let user = JSON.parse(content.content);
setQuotesList((notes) => [...notes, ...pageNotes]);
loadedQuotes = quotesList.length;
setIsFetching(() => false);
unsub();
}
// 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;
if (type === 'EVENT') {
if (content?.kind === Kind.Metadata) {
const user = content as NostrUserContent;
// users.push(user);
page.users[user.pubkey] = { ...user };
// return;
// }
// }
// });
return;
}
if (content?.kind === Kind.Text) {
const message = content as NostrNoteContent;
// setIsFetching(() => true);
// getEventQuotes(props.noteId, subId, offset);
// };
if (page.messages.find(m => m.id === message.id)) {
return;
}
const totalCount = () => props.stats.likes + props.stats.quotes + props.stats.reposts + props.stats.zaps;
page.messages.push(message);
return;
}
if (content?.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
page.postStats[stat.event_id] = { ...stat };
return;
}
if (content?.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
if (!page.mentions) {
page.mentions = {};
}
page.mentions[mention.id] = { ...mention };
return;
}
if (content?.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
page.noteActions[noteActions.event_id] = { ...noteActions };
return;
}
if (content?.kind === Kind.LinkMetadata) {
const metadata = JSON.parse(content.content);
const data = metadata.resources[0];
if (!data) {
return;
}
const preview = {
url: data.url,
title: data.md_title,
description: data.md_description,
mediaType: data.mimetype,
contentType: data.mimetype,
images: [data.md_image],
favicons: [data.icon_url],
};
setLinkPreviews(() => ({ [data.url]: preview }));
return;
}
}
});
setIsFetching(() => true);
getEventQuotes(props.noteId, subId, offset);
};
const getQuoteCount = () => {
if (!props.noteId) return;
const subId = `nr_qc_${props.noteId}_${APP_ID}`;
const unsub = subscribeTo(subId, (type,_, content) => {
if (type === 'EOSE') {
unsub();
}
if (type === 'EVENT') {
if (content?.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
setQuoteCount(() => quoteStats.count || 0);
}
}
});
getEventQuoteStats(props.noteId, subId);
}
const totalCount = () => props.stats.likes + (quoteCount() || props.stats.quotes || 0) + props.stats.reposts + props.stats.zaps;
return (
<Modal
@ -287,6 +395,12 @@ const ReactionsModal: Component<{
</button>
</div>
<Switch>
<Match when={!isFetching && totalCount() === 0}>
{intl.formatMessage(tPlaceholders.noReactionDetails)}
</Match>
</Switch>
<div class={styles.description}>
<Tabs.Root value={selectedTab()} onChange={setSelectedTab}>
<Tabs.List class={styles.tabs}>
@ -305,9 +419,9 @@ const ReactionsModal: Component<{
{intl.formatMessage(reactionsModal.tabs.reposts, { count: props.stats.reposts })}
</Tabs.Trigger>
</Show>
<Show when={props.stats.quotes > 0}>
<Show when={quoteCount() > 0}>
<Tabs.Trigger class={styles.tab} value={'quotes'} >
{intl.formatMessage(reactionsModal.tabs.quotes, { count: props.stats.quotes })}
{intl.formatMessage(reactionsModal.tabs.quotes, { count: quoteCount() })}
</Tabs.Trigger>
</Show>
@ -319,7 +433,12 @@ const ReactionsModal: Component<{
each={likeList}
fallback={
<Show when={!isFetching()}>
{intl.formatMessage(tPlaceholders.noLikeDetails)}
<Show
when={totalCount() > 0}
fallback={intl.formatMessage(tPlaceholders.noReactionDetails)}
>
{intl.formatMessage(tPlaceholders.noLikeDetails)}
</Show>
</Show>
}
>
@ -364,7 +483,12 @@ const ReactionsModal: Component<{
each={zapList}
fallback={
<Show when={!isFetching()}>
{intl.formatMessage(tPlaceholders.noZapDetails)}
<Show
when={totalCount() > 0}
fallback={intl.formatMessage(tPlaceholders.noReactionDetails)}
>
{intl.formatMessage(tPlaceholders.noZapDetails)}
</Show>
</Show>
}
>
@ -416,7 +540,12 @@ const ReactionsModal: Component<{
each={repostList}
fallback={
<Show when={!isFetching()}>
{intl.formatMessage(tPlaceholders.noRepostDetails)}
<Show
when={totalCount() > 0}
fallback={intl.formatMessage(tPlaceholders.noReactionDetails)}
>
{intl.formatMessage(tPlaceholders.noRepostDetails)}
</Show>
</Show>
}
>
@ -456,7 +585,36 @@ const ReactionsModal: Component<{
</Show>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value={'quotes'}>
All the quotes
<For
each={quotesList}
fallback={
<Show when={!isFetching()}>
<Show
when={totalCount() > 0}
fallback={intl.formatMessage(tPlaceholders.noReactionDetails)}
>
{intl.formatMessage(tPlaceholders.noQuoteDetails)}
</Show>
</Show>
}
>
{quote => (
<Note
note={quote}
shorten={true}
noteType="reaction"
onClick={props.onClose}
/>
)}
</For>
<Paginator
loadNextPage={() => {
const len = quotesList.length;
if (len === 0) return;
getQuotes(len);
}}
isSmall={true}
/>
</Tabs.Content>
</Tabs.Root>
</div>

View File

@ -140,6 +140,7 @@ export enum Kind {
UploadChunk = 10_000_135,
UserRelays=10_000_139,
RelayHint=10_000_141,
NoteQuoteStats=10_000_143,
WALLET_OPERATION = 10_000_300,
}

View File

@ -38,7 +38,7 @@ import {
} from "../types/primal";
import { APP_ID } from "../App";
import { useAccountContext } from "./AccountContext";
import { getEventZaps, setLinkPreviews } from "../lib/notes";
import { getEventQuoteStats, getEventZaps, setLinkPreviews } from "../lib/notes";
import { parseBolt11 } from "../utils";
import { getUserProfiles } from "../lib/profile";
@ -60,6 +60,7 @@ export type ThreadContextStore = {
reposts: Record<string, string> | undefined,
lastNote: PrimalNote | undefined,
topZaps: Record<string, TopZap[]>,
quoteCount: number,
actions: {
saveNotes: (newNotes: PrimalNote[]) => void,
clearNotes: () => void,
@ -92,6 +93,7 @@ export const initialData = {
reposts: {},
lastNote: undefined,
topZaps: {},
quoteCount: 0,
};
@ -116,6 +118,7 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
updateStore('noteId', noteId)
getThread(account?.publicKey, noteId, `thread_${APP_ID}`);
fetchTopZaps(noteId);
fetchNoteQuoteStats(noteId);
updateStore('isFetching', () => true);
}
@ -276,6 +279,12 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
updateStore('quoteCount', () => quoteStats.count || 0);
}
};
const savePage = (page: FeedPage) => {
@ -298,6 +307,10 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
getUserProfiles(pubkeys, `thread_pk_${APP_ID}`);
};
const fetchNoteQuoteStats = (noteId: string) => {
getEventQuoteStats(noteId, `thread_quote_stats_${APP_ID}`)
}
// SOCKET HANDLERS ------------------------------
const onMessage = (event: MessageEvent) => {
@ -368,6 +381,17 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
return;
}
}
if (subId === `thread_quote_stats_${APP_ID}`) {
if (type === 'EOSE') {
savePage(store.page);
}
if (type === 'EVENT') {
updatePage(content);
return;
}
}
};
const onSocketClose = (closeEvent: CloseEvent) => {

View File

@ -572,3 +572,14 @@ export const getEventZaps = (eventId: string, user_pubkey: string | undefined, s
{cache: ["event_zaps_by_satszapped", { event_id, user_pubkey, limit, offset }]},
]));
};
export const getEventQuoteStats = (eventId: string, subid: string) => {
const event_id = eventId.startsWith('note1') ? npubToHex(eventId) : eventId;
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["note_mentions_count", { event_id }]},
]));
};

View File

@ -207,6 +207,7 @@ const Thread: Component = () => {
<Note
note={primaryNote() as PrimalNote}
noteType="primary"
quoteCount={threadContext?.quoteCount}
/>
<Show when={account?.hasPublicKey()}>
<ReplyToNote

View File

@ -308,6 +308,8 @@ export const convertToNotes: ConvertToNotes = (page) => {
mentionedNotes,
mentionedUsers,
replyTo: replyTo && replyTo[1],
tags: msg.tags,
id: msg.id,
};
});
}

View File

@ -1014,6 +1014,11 @@ export const placeholders = {
description: 'Placeholder when the note is missing',
},
},
noReactionDetails: {
id: 'placeholders.noReactionDetails',
defaultMessage: 'No details for rections found',
description: 'Placeholder when there are no reaction details in reactions modal',
},
noLikeDetails: {
id: 'placeholders.noLikeDetails',
defaultMessage: 'No details for likes found',
@ -1029,6 +1034,11 @@ export const placeholders = {
defaultMessage: 'No details for reposts found',
description: 'Placeholder when there are no repost details in reactions modal',
},
noQuoteDetails: {
id: 'placeholders.noQuoteDetails',
defaultMessage: 'No details for quotes found',
description: 'Placeholder when there are no quote details in reactions modal',
},
addComment: {
id: 'placeholders.addComment',
defaultMessage: 'Add a comment...',

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

@ -221,6 +221,13 @@ export type NostrZapInfo = {
tags?: string[][],
};
export type NostrQuoteStatsInfo = {
kind: Kind.NoteQuoteStats,
content: string,
created_at?: number,
tags?: string[][],
};
export type NostrEventContent =
NostrNoteContent |
NostrUserContent |
@ -251,7 +258,8 @@ export type NostrEventContent =
PrimalUserRelays |
NostrBookmarks |
NostrRelayHint |
NostrZapInfo;
NostrZapInfo |
NostrQuoteStatsInfo;
export type NostrEvent = [
type: "EVENT",
@ -465,6 +473,8 @@ export type PrimalNote = {
mentionedNotes?: Record<string, PrimalNote>,
mentionedUsers?: Record<string, PrimalUser>,
replyTo?: string,
id: string,
tags: string[][],
};
export type PrimalFeed = {