diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000000..0696f84bbc --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,25 @@ +name: Deploy conversations branch to Github Pages + +on: + push: + branches: [ "conversations" ] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + pages: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - run: npm ci && npm run build + - uses: actions/upload-pages-artifact@v2 + with: + path: "dist" + - uses: actions/deploy-pages@v3 + + diff --git a/src/app.jsx b/src/app.jsx index 3e4a6879c5..b8aeeb1dd5 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -40,6 +40,8 @@ import Search from './pages/search'; import StatusRoute from './pages/status-route'; import Trending from './pages/trending'; import Welcome from './pages/welcome'; +import Conversations from './pages/conversations'; +import Conversation from './pages/conversation'; import { api, initAccount, @@ -416,6 +418,10 @@ function SecondaryRoutes({ isLoggedIn }) { } /> } /> + + } /> + } /> + )} } /> diff --git a/src/components/icon.jsx b/src/components/icon.jsx index 8b39f789f6..f22b738ab4 100644 --- a/src/components/icon.jsx +++ b/src/components/icon.jsx @@ -104,6 +104,7 @@ export const ICONS = { cloud: () => import('@iconify-icons/mingcute/cloud-line'), month: () => import('@iconify-icons/mingcute/calendar-month-line'), media: () => import('@iconify-icons/mingcute/photo-album-line'), + chat: () => import('@iconify-icons/mingcute/chat-1-line'), }; function Icon({ diff --git a/src/components/nav-menu.jsx b/src/components/nav-menu.jsx index 96b9ce4669..1ac724c1cd 100644 --- a/src/components/nav-menu.jsx +++ b/src/components/nav-menu.jsx @@ -182,6 +182,9 @@ function NavMenu(props) { )} + + Conversations + Lists diff --git a/src/pages/conversation.css b/src/pages/conversation.css new file mode 100644 index 0000000000..c720205571 --- /dev/null +++ b/src/pages/conversation.css @@ -0,0 +1,5 @@ +.participants { + display: flex; + padding: 10px; + justify-content: center; +} \ No newline at end of file diff --git a/src/pages/conversation.jsx b/src/pages/conversation.jsx new file mode 100644 index 0000000000..6c13cd4873 --- /dev/null +++ b/src/pages/conversation.jsx @@ -0,0 +1,267 @@ +import './conversation.css'; + +import { useMemo, useRef, useState, useEffect } from 'preact/hooks'; +import { useParams } from 'react-router-dom'; +import { useSnapshot } from 'valtio'; +import pRetry from 'p-retry'; + +import Link from '../components/link'; +import Timeline from '../components/timeline'; +import { api } from '../utils/api'; +import states, { saveStatus, getStatus, statusKey, threadifyStatus } from '../utils/states'; +import useTitle from '../utils/useTitle'; +import htmlContentLength from '../utils/html-content-length'; +import AccountBlock from '../components/account-block'; + +const LIMIT = 20; +const emptySearchParams = new URLSearchParams(); +const cachedStatusesMap = {}; + +function Conversation(props) { + const { masto, instance } = api(); + const [stateType, setStateType] = useState(null); + const [participants, setParticipants] = useState(); + const id = props?.id || useParams()?.id; + const snapStates = useSnapshot(states); + useTitle(`Conversation`, '/c/:id'); + const [statuses, setStatuses] = useState([]); + + async function getThreadForId(id) { + const sKey = statusKey(id, instance); + console.debug('initContext conv', id); + let heroTimer; + + const cachedStatuses = cachedStatusesMap[id]; + if (cachedStatuses) { + // Case 1: It's cached, let's restore them to make it snappy + const reallyCachedStatuses = cachedStatuses.filter( + (s) => states.statuses[sKey], + // Some are not cached in the global state, so we need to filter them out + ); + return reallyCachedStatuses; + } + + const heroFetch = () => + pRetry(() => masto.v1.statuses.$select(id).fetch(), { + retries: 4, + }); + const contextFetch = pRetry( + () => masto.v1.statuses.$select(id).context.fetch(), + { + retries: 8, + }, + ); + + const hasStatus = !!snapStates.statuses[sKey]; + let heroStatus = snapStates.statuses[sKey]; + if (hasStatus) { + console.debug('Hero status is cached'); + } else { + try { + heroStatus = await heroFetch(); + saveStatus(heroStatus, instance); + // Give time for context to appear + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } catch (e) { + console.error(e); + return; + } + } + + try { + const context = await contextFetch; + const { ancestors, descendants } = context; + console.log("ancestors", id, ancestors) + + const missingStatuses = new Set(); + ancestors.forEach((status) => { + saveStatus(status, instance, { + skipThreading: true, + }); + if ( + status.inReplyToId && + !ancestors.find((s) => s.id === status.inReplyToId) + ) { + missingStatuses.add(status.inReplyToId); + } + }); + const ancestorsIsThread = ancestors.every( + (s) => s.account.id === heroStatus.account.id, + ); + const nestedDescendants = []; + descendants.forEach((status) => { + saveStatus(status, instance, { + skipThreading: true, + }); + + if ( + status.inReplyToId && + !descendants.find((s) => s.id === status.inReplyToId) && + status.inReplyToId !== heroStatus.id + ) { + missingStatuses.add(status.inReplyToId); + } + + if (status.inReplyToAccountId === status.account.id) { + // If replying to self, it's part of the thread, level 1 + nestedDescendants.push(status); + } else if (status.inReplyToId === heroStatus.id) { + // If replying to the hero status, it's a reply, level 1 + nestedDescendants.push(status); + } else if ( + !status.inReplyToAccountId && + nestedDescendants.find((s) => s.id === status.inReplyToId) && + status.account.id === heroStatus.account.id + ) { + // If replying to hero's own statuses, it's part of the thread, level 1 + nestedDescendants.push(status); + } else { + // If replying to someone else, it's a reply to a reply, level 2 + const parent = descendants.find((s) => s.id === status.inReplyToId); + if (parent) { + if (!parent.__replies) { + parent.__replies = []; + } + parent.__replies.push(status); + } else { + // If no parent, something is wrong + console.warn('No parent found for', status); + } + } + }); + + console.log({ ancestors, descendants, nestedDescendants }); + if (missingStatuses.size) { + console.error('Missing statuses', [...missingStatuses]); + } + + const allStatuses = [ + ...ancestors.map((s) => states.statuses[statusKey(s.id, instance)]), + states.statuses[statusKey(id, instance)], + ...nestedDescendants.map((s) => states.statuses[statusKey(s.id, instance)]), + ]; + + console.log({ allStatuses }); + cachedStatusesMap[id] = allStatuses; + + // Let's threadify this one + // Note that all non-hero statuses will trigger saveStatus which will threadify them too + // By right, at this point, all descendant statuses should be cached + threadifyStatus(heroStatus, instance); + + return allStatuses; + } catch (e) { + console.error(e); + } + } + + const conversationsIterator = useRef(); + const latestConversationItem = useRef(); + async function fetchItems(firstLoad) { + if (!firstLoad) { + return {done: true, value: []}; + } + const allStatuses = []; + const value = await masto.v1.conversations.list({ + limit: LIMIT, + }); + const pointer = value?.filter((item) => item.lastStatus?.id == id)[0]; + const value2 = !!pointer ? value?.filter((convo) => { + const convoAccounts = convo.accounts.map((acc) => acc.acct); + const matchingAccounts = pointer.accounts.map((acc) => acc.acct); + return convoAccounts.length === matchingAccounts.length && + convoAccounts.every((val, index) => val === matchingAccounts[index]) + }) : []; + const value3 = value2?.map((item) => item.lastStatus) + if (value3?.length) { + for (const item of value3) { + const newStatuses = await getThreadForId(item.id) + newStatuses.forEach((item) => allStatuses.push(item)) + } + } + + setParticipants(pointer.accounts) + + return { + done: true, + value: allStatuses, + } + } + + async function checkForUpdates() { + try { + const pointer = getStatus(id, masto); + const results = await masto.v1.conversations + .list({ + since_id: latestConversationItem.current, + }) + .next(); + let { value } = results; + value = !!pointer ? value?.filter((convo) => { + const convoAccounts = convo.accounts.map((acc) => acc.acct); + const matchingAccounts = pointer.accounts.map((acc) => acc.acct); + return convoAccounts.length === matchingAccounts.length && + convoAccounts.every((val, index) => val === matchingAccounts[index]) + }) : []; + console.log( + 'checkForUpdates PRIVATE', + latestConversationItem.current, + value, + ); + if (value?.length) { + latestConversationItem.current = value[0].lastStatus.id; + return true; + } + return false; + } catch (e) { + return false; + } + } + + const participantsHeader = participants + ?
+ {participants.map((participant) => )} +
+ : ""; + + return ( + + ); +} + +const MEDIA_VIRTUAL_LENGTH = 140; +const POLL_VIRTUAL_LENGTH = 35; +const CARD_VIRTUAL_LENGTH = 70; +const WEIGHT_SEGMENT = 140; +const statusWeightCache = new Map(); +function calcStatusWeight(status) { + const cachedWeight = statusWeightCache.get(status.id); + if (cachedWeight) return cachedWeight; + const { spoilerText, content, mediaAttachments, poll, card } = status; + const length = htmlContentLength(spoilerText + content); + const mediaLength = mediaAttachments?.length ? MEDIA_VIRTUAL_LENGTH : 0; + const pollLength = (poll?.options?.length || 0) * POLL_VIRTUAL_LENGTH; + const cardLength = + card && (mediaAttachments?.length || poll?.options?.length) + ? 0 + : CARD_VIRTUAL_LENGTH; + const totalLength = length + mediaLength + pollLength + cardLength; + const weight = totalLength / WEIGHT_SEGMENT; + statusWeightCache.set(status.id, weight); + return weight; +} + +export default Conversation; diff --git a/src/pages/conversations.css b/src/pages/conversations.css new file mode 100644 index 0000000000..0ada915d1d --- /dev/null +++ b/src/pages/conversations.css @@ -0,0 +1,4 @@ +.row { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/src/pages/conversations.jsx b/src/pages/conversations.jsx new file mode 100644 index 0000000000..91650ac59d --- /dev/null +++ b/src/pages/conversations.jsx @@ -0,0 +1,109 @@ +import './conversations.css'; + +import { useEffect, useReducer, useRef, useState } from 'preact/hooks'; +import pRetry from 'p-retry'; + +import Icon from '../components/icon'; +import Link from '../components/link'; +import Loader from '../components/loader'; +import Modal from '../components/modal'; +import NavMenu from '../components/nav-menu'; +import { api } from '../utils/api'; +import useTitle from '../utils/useTitle'; +import AccountBlock from '../components/account-block'; +import states from '../utils/states'; + +const LIMIT = 20; + +function Conversations({instance}) { + const { masto } = api(); + useTitle(`Conversations`, `/c`); + const [uiState, setUIState] = useState('default'); + + const [reloadCount, reload] = useReducer((c) => c + 1, 0); + const [conversations, setConversations] = useState([]); + useEffect(() => { + setUIState('loading'); + (async () => { + try { + const conversationsFetch = () => + pRetry(() => masto.v1.conversations.list({limit: LIMIT}), { + retries: 4 + }) + const cc = []; + + const conversationsRaw = await conversationsFetch(); + conversationsRaw.forEach(u => { + u.accounts.forEach((account) => states.accounts[account.id] = account) + const conversation = { + actors: u.accounts.map(account => account.id), + id: u.lastStatus?.id, + date: u.lastStatus?.editedAt || u.lastStatus?.createdAt, + } + const withSameActorsIndex = cc.findIndex(c => c.actors.join(',') == conversation.actors.join(',')); + if (withSameActorsIndex == -1) { + cc.push(conversation) + } else if (cc[withSameActorsIndex].date < conversation.date) { + cc.set(withSameActorsIndex, conversation) + } + }) + + // TODO: unread first + const conversations = cc.sort((a, b) => { a.date.localeCompare(b.date)}) + + console.log(cc); + console.log("accounts", states.accounts) + setConversations(cc); + setUIState('default'); + } catch (e) { + console.error(e); + setUIState('error'); + } + })(); + }, [reloadCount]); + + return ( +
+
+
+
+
+ + + + +
+

Conversations

+
+
+
+
+ {conversations.length > 0 ? ( + + ) : uiState === 'loading' ? ( +

+ +

+ ) : uiState === 'error' ? ( +

Unable to load conversations.

+ ) : ( +

No conversations yet.

+ )} +
+
+
+ ); +} + +export default Conversations;