diff --git a/pkg/interface/CONTRIBUTING.md b/pkg/interface/CONTRIBUTING.md index 711e359d8..f3bfbb5a0 100644 --- a/pkg/interface/CONTRIBUTING.md +++ b/pkg/interface/CONTRIBUTING.md @@ -32,7 +32,7 @@ same (if [developing on a local development ship][local]). Then, from 'pkg/interface': ``` -npm install +npm ci npm run start ``` @@ -59,7 +59,7 @@ module.exports = { ``` The dev environment will attempt to match the subdomain against the keys of this -object, and if matched will proxy to the corresponding URL. For example, the +object, and if matched will proxy to the corresponding URL. For example, the above config will proxy `zod.localhost:9000` to `http://localhost:8080`, `bus.localhost:9000` to `http://localhost:8081` and so on and so forth. If no match is found, then it will fallback to the `URL` property. @@ -71,7 +71,7 @@ linter and for usage through the command, do the following: ```bash $ cd ./pkg/interface -$ npm install +$ npm ci $ npm run lint ``` diff --git a/pkg/interface/package.json b/pkg/interface/package.json index ae2d1129c..831507293 100644 --- a/pkg/interface/package.json +++ b/pkg/interface/package.json @@ -98,8 +98,9 @@ "lint-file": "eslint", "tsc": "tsc", "tsc:watch": "tsc --watch", + "preinstall": "./preinstall.sh", "build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js", - "build:prod": "cd ../npm/api && npm i && cd ../../interface && cross-env NODE_ENV=production webpack --config config/webpack.prod.js", + "build:prod": "cd ../npm/api && npm ci && cd ../../interface && cross-env NODE_ENV=production webpack --config config/webpack.prod.js", "start": "webpack-dev-server --config config/webpack.dev.js", "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/pkg/interface/preinstall.sh b/pkg/interface/preinstall.sh new file mode 100755 index 000000000..4f35cfc0f --- /dev/null +++ b/pkg/interface/preinstall.sh @@ -0,0 +1,12 @@ +#!/bin/sh +cd ../npm + +for i in $(find . -type d -maxdepth 1) ; do + packageJson="${i}/package.json" + if [ -f "${packageJson}" ]; then + echo "installing ${i}..." + cd ./${i} + npm ci + cd .. + fi +done \ No newline at end of file diff --git a/pkg/interface/src/logic/api/hark.ts b/pkg/interface/src/logic/api/hark.ts index b807201a1..42fe0ae31 100644 --- a/pkg/interface/src/logic/api/hark.ts +++ b/pkg/interface/src/logic/api/hark.ts @@ -4,6 +4,11 @@ import { dateToDa, decToUd } from '../lib/util'; import { NotifIndex, IndexedNotification, Association, GraphNotifDescription } from '@urbit/api'; import { BigInteger } from 'big-integer'; import { getParentIndex } from '../lib/notification'; +import useHarkState from '../state/hark'; + +function getHarkSize() { + return useHarkState.getState().notifications.size ?? 0; +} export class HarkApi extends BaseApi { private harkAction(action: any): Promise { @@ -172,10 +177,10 @@ export class HarkApi extends BaseApi { } async getMore(): Promise { - const offset = this.store.state['notifications']?.size || 0; + const offset = getHarkSize(); const count = 3; await this.getSubset(offset, count, false); - return offset === (this.store.state.notifications?.size || 0); + return offset === getHarkSize(); } async getSubset(offset:number, count:number, isArchive: boolean) { diff --git a/pkg/interface/src/logic/lib/formGroup.ts b/pkg/interface/src/logic/lib/formGroup.ts new file mode 100644 index 000000000..e2f7ec7d7 --- /dev/null +++ b/pkg/interface/src/logic/lib/formGroup.ts @@ -0,0 +1,18 @@ +import React from "react"; + +export type SubmitHandler = () => Promise; +interface IFormGroupContext { + addSubmit: (id: string, submit: SubmitHandler) => void; + onDirty: (id: string, touched: boolean) => void; + onErrors: (id: string, errors: boolean) => void; + submitAll: () => Promise; +} + +const fallback: IFormGroupContext = { + addSubmit: () => {}, + onDirty: () => {}, + onErrors: () => {}, + submitAll: () => Promise.resolve(), +}; + +export const FormGroupContext = React.createContext(fallback); diff --git a/pkg/interface/src/logic/lib/hark.ts b/pkg/interface/src/logic/lib/hark.ts index 54dd37e09..2e7a6b3d9 100644 --- a/pkg/interface/src/logic/lib/hark.ts +++ b/pkg/interface/src/logic/lib/hark.ts @@ -1,6 +1,6 @@ import bigInt, { BigInteger } from 'big-integer'; import f from 'lodash/fp'; -import { Unreads } from '@urbit/api'; +import { Unreads, NotificationGraphConfig } from '@urbit/api'; export function getLastSeen( unreads: Unreads, @@ -34,3 +34,13 @@ export function getNotificationCount( .map(index => unread[index]?.notifications?.length || 0) .reduce(f.add, 0); } + +export function isWatching( + config: NotificationGraphConfig, + graph: string, + index = "/" +) { + return !!config.watching.find( + watch => watch.graph === graph && watch.index === index + ); +} diff --git a/pkg/interface/src/logic/lib/platform.ts b/pkg/interface/src/logic/lib/platform.ts index b02870fea..19234e0dd 100644 --- a/pkg/interface/src/logic/lib/platform.ts +++ b/pkg/interface/src/logic/lib/platform.ts @@ -4,3 +4,7 @@ const ua = window.navigator.userAgent; export const IS_IOS = ua.includes('iPhone'); export const IS_SAFARI = ua.includes('Safari') && !ua.includes('Chrome'); + +export const IS_ANDROID = ua.includes('Android'); + +export const IS_MOBILE = IS_IOS || IS_ANDROID; diff --git a/pkg/interface/src/logic/lib/useLazyScroll.ts b/pkg/interface/src/logic/lib/useLazyScroll.ts index a7b7235ee..df0bd74ad 100644 --- a/pkg/interface/src/logic/lib/useLazyScroll.ts +++ b/pkg/interface/src/logic/lib/useLazyScroll.ts @@ -41,6 +41,12 @@ export function useLazyScroll( } }, [count]); + useEffect(() => { + if(!ready) { + setIsDone(false); + } + }, [ready]); + useEffect(() => { if (!ref.current || isDone || !ready) { return; @@ -58,7 +64,7 @@ export function useLazyScroll( return () => { ref.current?.removeEventListener('scroll', onScroll); }; - }, [ref?.current, count, ready]); + }, [ref?.current, ready, isDone]); return { isDone, isLoading }; } diff --git a/pkg/interface/src/logic/lib/useRunIO.ts b/pkg/interface/src/logic/lib/useRunIO.ts index 12d8628cd..bfaee2c54 100644 --- a/pkg/interface/src/logic/lib/useRunIO.ts +++ b/pkg/interface/src/logic/lib/useRunIO.ts @@ -10,7 +10,7 @@ export function useRunIO( io: (i: I) => Promise, after: (o: O) => void, key: string -) { +): () => Promise { const [resolve, setResolve] = useState<() => void>(() => () => {}); const [reject, setReject] = useState<(e: any) => void>(() => () => {}); const [output, setOutput] = useState(null); diff --git a/pkg/interface/src/logic/lib/util.ts b/pkg/interface/src/logic/lib/util.ts index 6e6ef0740..f34e8f76b 100644 --- a/pkg/interface/src/logic/lib/util.ts +++ b/pkg/interface/src/logic/lib/util.ts @@ -63,6 +63,16 @@ export function unixToDa(unix: number) { return DA_UNIX_EPOCH.add(timeSinceEpoch); } +export function dmCounterparty(resource: string) { + const [,,ship,name] = resource.split('/'); + return ship === `~${window.ship}` ? `~${name.slice(4)}` : ship; +} + +export function isDm(resource: string) { + const [,,,name] = resource.split('/'); + return name.startsWith('dm--'); +} + export function makePatDa(patda: string) { return bigInt(udToDec(patda)); } diff --git a/pkg/interface/src/logic/state/contact.ts b/pkg/interface/src/logic/state/contact.ts index 2a2bf0f78..a57a183ff 100644 --- a/pkg/interface/src/logic/state/contact.ts +++ b/pkg/interface/src/logic/state/contact.ts @@ -8,12 +8,6 @@ export interface ContactState extends BaseState { isContactPublic: boolean; nackedContacts: Set; // fetchIsAllowed: (entity, name, ship, personal) => Promise; -}; - -export function useContact(ship: string) { - return useContactState( - useCallback(s => s.contacts[ship] as Contact | null, [ship]) - ); } const useContactState = createState('Contact', { @@ -35,4 +29,10 @@ const useContactState = createState('Contact', { // }, }, ['nackedContacts']); +export function useContact(ship: string) { + return useContactState( + useCallback(s => s.contacts[ship] as Contact | null, [ship]) + ); +} + export default useContactState; diff --git a/pkg/interface/src/logic/state/group.ts b/pkg/interface/src/logic/state/group.ts index 4fe3999f6..c8f5eef2a 100644 --- a/pkg/interface/src/logic/state/group.ts +++ b/pkg/interface/src/logic/state/group.ts @@ -16,7 +16,7 @@ const useGroupState = createState('Group', { }, ['groups']); export function useGroup(group: string) { - return useGroupState(useCallback(s => s.groups[group], [group])); + return useGroupState(useCallback(s => s.groups[group] as Group | undefined, [group])); } export function useGroupForAssoc(association: Association) { diff --git a/pkg/interface/src/logic/state/hark.ts b/pkg/interface/src/logic/state/hark.ts index d02402c09..047723c94 100644 --- a/pkg/interface/src/logic/state/hark.ts +++ b/pkg/interface/src/logic/state/hark.ts @@ -15,7 +15,7 @@ export interface HarkState extends BaseState { notifications: BigIntOrderedMap; notificationsCount: number; notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere - notificationsGroupConfig: []; // TODO type this + notificationsGroupConfig: string[]; unreads: Unreads; }; diff --git a/pkg/interface/src/logic/state/metadata.ts b/pkg/interface/src/logic/state/metadata.ts index fabffca86..a3c462193 100644 --- a/pkg/interface/src/logic/state/metadata.ts +++ b/pkg/interface/src/logic/state/metadata.ts @@ -1,4 +1,6 @@ -import { MetadataUpdatePreview, Associations } from "@urbit/api"; +import { useCallback } from 'react'; +import _ from 'lodash'; +import { MetadataUpdatePreview, Association, Associations } from "@urbit/api"; import { BaseState, createState } from "./base"; @@ -9,6 +11,19 @@ export interface MetadataState extends BaseState { // preview: (group: string) => Promise; }; +export function useAssocForGraph(graph: string) { + return useMetadataState(useCallback(s => s.associations.graph[graph] as Association | undefined, [graph])); +} + +export function useAssocForGroup(group: string) { + return useMetadataState(useCallback(s => s.associations.groups[group] as Association | undefined, [group])); +} + +export function useGraphsForGroup(group: string) { + const graphs = useMetadataState(s => s.associations.graph); + return _.pickBy(graphs, (a: Association) => a.group === group); +} + const useMetadataState = createState('Metadata', { associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} }, // preview: async (group): Promise => { @@ -54,4 +69,4 @@ const useMetadataState = createState('Metadata', { }); -export default useMetadataState; \ No newline at end of file +export default useMetadataState; diff --git a/pkg/interface/src/logic/state/settings.ts b/pkg/interface/src/logic/state/settings.ts index 02e96fffe..767617a62 100644 --- a/pkg/interface/src/logic/state/settings.ts +++ b/pkg/interface/src/logic/state/settings.ts @@ -58,7 +58,7 @@ const useSettingsState = createState('Settings', { categories: leapCategories, }, tutorial: { - seen: false, + seen: true, joined: undefined } }); diff --git a/pkg/interface/src/views/App.js b/pkg/interface/src/views/App.js index 3cd6478b0..9e14446a2 100644 --- a/pkg/interface/src/views/App.js +++ b/pkg/interface/src/views/App.js @@ -53,6 +53,7 @@ const Root = withState(styled.div` } display: flex; flex-flow: column nowrap; + touch-action: none; * { scrollbar-width: thin; diff --git a/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx b/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx index 306d221ef..4839817c6 100644 --- a/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx +++ b/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx @@ -1,4 +1,5 @@ /* eslint-disable max-lines-per-function */ +import bigInt from 'big-integer'; import React, { useState, useEffect, @@ -19,7 +20,8 @@ import { writeText, useShowNickname, useHideAvatar, - useHovering + useHovering, + daToUnix } from '~/logic/lib/util'; import { Group, @@ -295,15 +297,20 @@ class ChatMessage extends Component { ); } + const date = daToUnix(bigInt(msg.index.split('/')[1])); + const nextDate = nextMsg ? ( + daToUnix(bigInt(nextMsg.index.split('/')[1])) + ) : null; + const dayBreak = nextMsg && - new Date(msg['time-sent']).getDate() !== - new Date(nextMsg['time-sent']).getDate(); + new Date(date).getDate() !== + new Date(nextDate).getDate(); const containerClass = `${isPending ? 'o-40' : ''} ${className}`; const timestamp = moment - .unix(msg['time-sent'] / 1000) + .unix(date / 1000) .format(renderSigil ? 'h:mm A' : 'h:mm'); const messageProps = { @@ -339,7 +346,7 @@ class ChatMessage extends Component { style={style} > {dayBreak && !isLastRead ? ( - + ) : null} {renderSigil ? ( @@ -357,7 +364,7 @@ class ChatMessage extends Component { association={association} api={api} dayBreak={dayBreak} - when={msg['time-sent']} + when={date} ref={unreadMarkerRef} /> ) : null} @@ -387,8 +394,10 @@ export const MessageAuthor = ({ const dark = theme === 'dark' || (theme === 'auto' && osDark); const contacts = useContactState((state) => state.contacts); + const date = daToUnix(bigInt(msg.index.split('/')[1])); + const datestamp = moment - .unix(msg['time-sent'] / 1000) + .unix(date / 1000) .format(DATESTAMP_FORMAT); const contact = ((msg.author === window.ship && showOurContact) || diff --git a/pkg/interface/src/views/apps/chat/css/custom.css b/pkg/interface/src/views/apps/chat/css/custom.css index 3e6b6bf4c..794356a26 100644 --- a/pkg/interface/src/views/apps/chat/css/custom.css +++ b/pkg/interface/src/views/apps/chat/css/custom.css @@ -98,8 +98,15 @@ h2 { font-family: 'Inter', sans-serif; } +.embed-container:not(.embed-container .embed-container):not(.links) { + padding: 0px 8px 8px 8px; +} + .embed-container iframe { max-width: 100%; + width: 100%; + height: 100%; + margin-top: 8px; } .mh-16 { diff --git a/pkg/interface/src/views/apps/launch/app.js b/pkg/interface/src/views/apps/launch/app.js index 400e2e1b9..e9fa29426 100644 --- a/pkg/interface/src/views/apps/launch/app.js +++ b/pkg/interface/src/views/apps/launch/app.js @@ -46,7 +46,7 @@ const ScrollbarLessBox = styled(Box)` const tutSelector = f.pick(['tutorialProgress', 'nextTutStep', 'hideGroups']); export default function LaunchApp(props) { - const connection = { props }; + const { connection } = props; const baseHash = useLaunchState(state => state.baseHash); const [hashText, setHashText] = useState(baseHash); const [exitingTut, setExitingTut] = useState(false); diff --git a/pkg/interface/src/views/apps/notifications/graph.tsx b/pkg/interface/src/views/apps/notifications/graph.tsx index 2f8180d7c..2dfdd3f69 100644 --- a/pkg/interface/src/views/apps/notifications/graph.tsx +++ b/pkg/interface/src/views/apps/notifications/graph.tsx @@ -1,65 +1,80 @@ -import React, { ReactNode, useCallback } from 'react'; -import moment from 'moment'; -import { Row, Box, Col, Text, Anchor, Icon, Action } from '@tlon/indigo-react'; -import { Link, useHistory } from 'react-router-dom'; -import _ from 'lodash'; +import React, { ReactNode, useCallback } from "react"; +import moment from "moment"; +import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react"; +import { Link, useHistory } from "react-router-dom"; +import _ from "lodash"; import { GraphNotifIndex, GraphNotificationContents, Associations, Rolodex, - Groups -} from '~/types'; -import { Header } from './header'; -import { cite, deSig, pluralize, useShowNickname } from '~/logic/lib/util'; -import Author from '~/views/components/Author'; -import GlobalApi from '~/logic/api/global'; -import { getSnippet } from '~/logic/lib/publish'; -import styled from 'styled-components'; -import { MentionText } from '~/views/components/MentionText'; -import ChatMessage from '../chat/components/ChatMessage'; -import useContactState from '~/logic/state/contact'; -import useGroupState from '~/logic/state/group'; -import useMetadataState from '~/logic/state/metadata'; -import {PermalinkEmbed} from '../permalinks/embed'; -import {parsePermalink, referenceToPermalink} from '~/logic/lib/permalinks'; + Groups, +} from "~/types"; +import { Header } from "./header"; +import { + cite, + deSig, + pluralize, + useShowNickname, + isDm, +} from "~/logic/lib/util"; +import { GraphContentWide } from "~/views/landscape/components/Graph/GraphContentWide"; +import Author from "~/views/components/Author"; +import GlobalApi from "~/logic/api/global"; +import styled from "styled-components"; +import useContactState from "~/logic/state/contact"; +import useGroupState from "~/logic/state/group"; +import useMetadataState, { + useAssocForGraph, + useAssocForGroup, +} from "~/logic/state/metadata"; +import { PermalinkEmbed } from "../permalinks/embed"; +import { parsePermalink, referenceToPermalink } from "~/logic/lib/permalinks"; +import { Post, Group, Association } from "@urbit/api"; +import { BigInteger } from "big-integer"; + +const TruncBox = styled(Box)<{ truncate?: number }>` + -webkit-line-clamp: ${(p) => p.truncate ?? "unset"}; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + color: ${(p) => p.theme.colors.black}; +`; function getGraphModuleIcon(module: string) { - if (module === 'link') { - return 'Collection'; + if (module === "link") { + return "Collection"; } - if(module === 'post') { - return 'Groups'; + if (module === "post") { + return "Groups"; } return _.capitalize(module); } -const FilterBox = styled(Box)` - background: linear-gradient( - to bottom, - transparent, - ${(p) => p.theme.colors.white} - ); -`; - -function describeNotification(description: string, plural: boolean): string { +function describeNotification( + description: string, + plural: boolean, + isDm: boolean, + singleAuthor: boolean +): string { switch (description) { - case 'post': - return 'replied to you'; - case 'link': - return `added ${pluralize('new link', plural)} to`; - case 'comment': - return `left ${pluralize('comment', plural)} on`; - case 'edit-comment': - return `updated ${pluralize('comment', plural)} on`; - case 'note': - return `posted ${pluralize('note', plural)} to`; - case 'edit-note': - return `updated ${pluralize('note', plural)} in`; - case 'mention': - return 'mentioned you on'; - case 'message': - return `sent ${pluralize('message', plural)} to`; + case "post": + return singleAuthor ? "replied to you" : "Your post received replies"; + case "link": + return `New link${plural ? "s" : ""} in`; + case "comment": + return `New comment${plural ? "s" : ""} on`; + case "note": + return `New Note${plural ? "s" : ""} in`; + case "edit-note": + return `updated ${pluralize("note", plural)} in`; + case "mention": + return singleAuthor ? "mentioned you in" : "You were mentioned in"; + case "message": + if (isDm) { + return "messaged you"; + } + return `New message${plural ? "s" : ""} in`; default: return description; } @@ -67,105 +82,88 @@ function describeNotification(description: string, plural: boolean): string { const GraphUrl = ({ contents, api }) => { const [{ text }, link] = contents; - - if('reference' in link) { + if ("reference" in link) { return ( - ); + /> + ); } return ( - - - + + + {text} ); +}; + +function ContentSummary({ icon, name, author, to }) { + return ( + + + + + + {name} + + + + + by + + + + + + ); } -export const GraphNodeContent = ({ - group, - association, - post, - mod, - index, -}) => { +export const GraphNodeContent = ({ post, mod, index, hidden, association }) => { const { contents } = post; - const idx = index.slice(1).split('/'); - if (mod === 'link') { - if (idx.length === 1) { - return ; - } else if (idx.length === 3) { - return ; - } - return null; - } - if (mod === 'publish') { - if (idx[1] === '2') { - return ( - - ); - } else if (idx[1] === '1') { - const [{ text: header }, { text: body }] = contents; - const snippet = getSnippet(body); - return ( - - - {header} - - - {snippet} - - - - ); - } - } - if(mod === 'post') { - return ; - } - - if (mod === 'chat') { + const idx = index.slice(1).split("/"); + const { group, resource } = association; + const url = getNodeUrl(mod, hidden, group, resource, index); + if (mod === "link" && idx.length === 1) { + const [{ text: title }] = contents; return ( - - - + ); } - return null; + if (mod === "publish" && idx[1] === "1") { + const [{ text: title }] = contents; + return ( + + ); + } + return ( + + + + ); }; function getNodeUrl( @@ -175,78 +173,103 @@ function getNodeUrl( graph: string, index: string ) { - if (hidden && mod === 'chat') { - groupPath = '/messages'; + if (hidden && mod === "chat") { + groupPath = "/messages"; } else if (hidden) { - groupPath = '/home'; + groupPath = "/home"; } const graphUrl = `/~landscape${groupPath}/resource/${mod}${graph}`; - const idx = index.slice(1).split('/'); - if (mod === 'publish') { - const [noteId] = idx; - return `${graphUrl}/note/${noteId}`; - } else if (mod === 'link') { - const [linkId] = idx; - return `${graphUrl}/index/${linkId}`; - } else if (mod === 'chat') { - if(idx.length > 0) { + const idx = index.slice(1).split("/"); + if (mod === "publish") { + console.log(idx); + const [noteId, kind, commId] = idx; + const selected = kind === "2" ? `?selected=${commId}` : ""; + return `${graphUrl}/note/${noteId}${selected}`; + } else if (mod === "link") { + const [linkId, commId] = idx; + return `${graphUrl}/index/${linkId}${commId ? `?selected=${commId}` : ""}`; + } else if (mod === "chat") { + if (idx.length > 0) { return `${graphUrl}?msg=${idx[0]}`; } return graphUrl; - } else if( mod === 'post') { + } else if (mod === "post") { return `/~landscape${groupPath}/feed${index}`; } - return ''; + return ""; } -const GraphNode = ({ - post, - author, - mod, - description, - time, - index, - graph, - groupPath, - group, - read, - onRead, - showContact = false -}) => { - author = deSig(author); - const history = useHistory(); - const contacts = useContactState((state) => state.contacts); - const nodeUrl = getNodeUrl(mod, group?.hidden, groupPath, graph, index); - const association = useMetadataState( - useCallback(s => s.associations.graph[graph], [graph]) +interface PostsByAuthor { + author: string; + posts: Post[]; +} +const GraphNodes = (props: { + posts: Post[]; + graph: string; + hideAuthors?: boolean; + group?: Group; + groupPath: string; + description: string; + index: string; + mod: string; + association: Association; + hidden: boolean; +}) => { + const { + posts, + mod, + hidden, + index, + description, + hideAuthors = false, + association, + } = props; + + const postsByConsecAuthor = _.reduce( + posts, + (acc: PostsByAuthor[], val: Post, key: number) => { + const lent = acc.length; + if (lent > 0 && acc?.[lent - 1]?.author === val.author) { + const last = acc[lent - 1]; + const rest = acc.slice(0, -1); + return [...rest, { ...last, posts: [...last.posts, val] }]; + } + return [...acc, { author: val.author, posts: [val] }]; + }, + [] ); - const onClick = useCallback(() => { - if (!read) { - onRead(); - } - history.push(nodeUrl); - }, [read, onRead]); - return ( - - - {showContact && ( - - )} - - - - - + <> + {_.map(postsByConsecAuthor, ({ posts, author }, idx) => { + const time = posts[0]?.["time-sent"]; + return ( + + {!hideAuthors && ( + + )} + + {_.map(posts, (post) => ( +