From 8116ba1d454a572d084ac30a43d62e39ca261c18 Mon Sep 17 00:00:00 2001 From: Matthieu Rakotojaona Date: Tue, 5 Dec 2023 18:23:10 +0100 Subject: [PATCH 1/6] Add menu for listing conversations --- src/app.jsx | 2 + src/components/icon.jsx | 1 + src/components/nav-menu.jsx | 3 + src/pages/conversations.css | 40 ++++++++++++ src/pages/conversations.jsx | 125 ++++++++++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+) create mode 100644 src/pages/conversations.css create mode 100644 src/pages/conversations.jsx diff --git a/src/app.jsx b/src/app.jsx index 3e4a6879c5..2e3a05150c 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -40,6 +40,7 @@ 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 { api, initAccount, @@ -416,6 +417,7 @@ 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/conversations.css b/src/pages/conversations.css new file mode 100644 index 0000000000..b707579d58 --- /dev/null +++ b/src/pages/conversations.css @@ -0,0 +1,40 @@ +.conversation-form { + padding: 8px 0; + display: flex; + gap: 8px; + flex-direction: column; +} + +.conversation-form-row :is(input[type='text'], select) { + width: 100%; + appearance: none; +} + +.conversation-form-row .label-block { + display: flex; + padding: 8px 0; + gap: 4px; +} + +.conversation-form-footer { + display: flex; + gap: 8px; + justify-content: space-between; +} +.conversation-form-footer button[type='submit'] { + padding-inline: 24px; +} + +#conversation-manage-members-container ul { + display: block; + conversation-style: none; + padding: 8px 0; + margin: 0; +} +#conversation-manage-members-container ul li { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + padding: 8px 0; +} diff --git a/src/pages/conversations.jsx b/src/pages/conversations.jsx new file mode 100644 index 0000000000..0d0f6a91e1 --- /dev/null +++ b/src/pages/conversations.jsx @@ -0,0 +1,125 @@ +import './conversations.css'; + +import { useEffect, useReducer, useRef, useState } from 'preact/hooks'; + +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 NameText from '../components/name-text'; + +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 conversationsRaw = await masto.v1.conversations.list({ + limit: LIMIT + }); + const cc = []; + + conversationsRaw.forEach(u => { + const conversation = { + actors: u.accounts.map(account => account.displayName + ' (@' + account.acct + ')').join(', '), + id: u.lastStatus?.id, + date: u.lastStatus?.editedAt || u.lastStatus?.createdAt, + } + const withSameActorsIndex = cc.findIndex(c => c.actors == conversation.actors); + 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); + 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; From 95ae4e97eb8475d411521ff0ac8413dbd35c0a16 Mon Sep 17 00:00:00 2001 From: Matthieu Rakotojaona Date: Sun, 10 Dec 2023 17:36:35 +0100 Subject: [PATCH 2/6] Add simple conversation view --- src/app.jsx | 6 +- src/pages/conversation.jsx | 254 +++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 src/pages/conversation.jsx diff --git a/src/app.jsx b/src/app.jsx index 2e3a05150c..b8aeeb1dd5 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -41,6 +41,7 @@ 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, @@ -417,7 +418,10 @@ function SecondaryRoutes({ isLoggedIn }) { } /> } /> - } /> + + } /> + } /> + )} } /> diff --git a/src/pages/conversation.jsx b/src/pages/conversation.jsx new file mode 100644 index 0000000000..20450d059a --- /dev/null +++ b/src/pages/conversation.jsx @@ -0,0 +1,254 @@ +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'; + +const LIMIT = 20; +const emptySearchParams = new URLSearchParams(); +const cachedStatusesMap = {}; + +function Conversation(props) { + const { masto, instance } = api(); + const [stateType, setStateType] = useState(null); + 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)) + } + } + + 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; + } + } + + 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; From 68652825c718bb9a80e34ea3ddf5d5d14cd1e554 Mon Sep 17 00:00:00 2001 From: Matthieu Rakotojaona Date: Sun, 10 Dec 2023 18:49:06 +0100 Subject: [PATCH 3/6] Display participants in conversation --- src/pages/conversation.css | 5 +++++ src/pages/conversation.jsx | 13 +++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/pages/conversation.css 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 index 20450d059a..6c13cd4873 100644 --- a/src/pages/conversation.jsx +++ b/src/pages/conversation.jsx @@ -1,3 +1,5 @@ +import './conversation.css'; + import { useMemo, useRef, useState, useEffect } from 'preact/hooks'; import { useParams } from 'react-router-dom'; import { useSnapshot } from 'valtio'; @@ -9,6 +11,7 @@ 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(); @@ -17,6 +20,7 @@ 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'); @@ -178,6 +182,8 @@ function Conversation(props) { } } + setParticipants(pointer.accounts) + return { done: true, value: allStatuses, @@ -214,10 +220,17 @@ function Conversation(props) { } } + const participantsHeader = participants + ?
+ {participants.map((participant) => )} +
+ : ""; + return ( Date: Sun, 10 Dec 2023 19:38:43 +0100 Subject: [PATCH 4/6] Display full account-block in conversations list --- src/pages/conversations.css | 44 ++++--------------------------------- src/pages/conversations.jsx | 41 ++++++++++------------------------ 2 files changed, 15 insertions(+), 70 deletions(-) diff --git a/src/pages/conversations.css b/src/pages/conversations.css index b707579d58..0ada915d1d 100644 --- a/src/pages/conversations.css +++ b/src/pages/conversations.css @@ -1,40 +1,4 @@ -.conversation-form { - padding: 8px 0; - display: flex; - gap: 8px; - flex-direction: column; -} - -.conversation-form-row :is(input[type='text'], select) { - width: 100%; - appearance: none; -} - -.conversation-form-row .label-block { - display: flex; - padding: 8px 0; - gap: 4px; -} - -.conversation-form-footer { - display: flex; - gap: 8px; - justify-content: space-between; -} -.conversation-form-footer button[type='submit'] { - padding-inline: 24px; -} - -#conversation-manage-members-container ul { - display: block; - conversation-style: none; - padding: 8px 0; - margin: 0; -} -#conversation-manage-members-container ul li { - display: flex; - gap: 8px; - align-items: center; - justify-content: space-between; - padding: 8px 0; -} +.row { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/src/pages/conversations.jsx b/src/pages/conversations.jsx index 0d0f6a91e1..4a8fd969d9 100644 --- a/src/pages/conversations.jsx +++ b/src/pages/conversations.jsx @@ -9,7 +9,8 @@ import Modal from '../components/modal'; import NavMenu from '../components/nav-menu'; import { api } from '../utils/api'; import useTitle from '../utils/useTitle'; -import NameText from '../components/name-text'; +import AccountBlock from '../components/account-block'; +import states from '../utils/states'; const LIMIT = 20; @@ -30,12 +31,13 @@ function Conversations({instance}) { const cc = []; conversationsRaw.forEach(u => { + u.accounts.forEach((account) => states.accounts[account.id] = account) const conversation = { - actors: u.accounts.map(account => account.displayName + ' (@' + account.acct + ')').join(', '), + 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 == conversation.actors); + const withSameActorsIndex = cc.findIndex(c => c.actors.join(',') == conversation.actors.join(',')); if (withSameActorsIndex == -1) { cc.push(conversation) } else if (cc[withSameActorsIndex].date < conversation.date) { @@ -47,6 +49,7 @@ function Conversations({instance}) { 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) { @@ -68,17 +71,7 @@ function Conversations({instance}) {

Conversations

-
- {/* - - */} -
+
@@ -87,22 +80,10 @@ function Conversations({instance}) { {conversations.map((conversation) => (
  • - - {conversation.actors} - - {/* */} +
    + + {conversation.actors.map((actor) => ) } +
  • ))} From 0ac79ddfbc01965e00f9899ba2ce84c0c5b6c6e6 Mon Sep 17 00:00:00 2001 From: Matthieu Rakotojaona Date: Sun, 10 Dec 2023 19:50:36 +0100 Subject: [PATCH 5/6] Use retries to make conversations work better --- src/pages/conversations.jsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/pages/conversations.jsx b/src/pages/conversations.jsx index 4a8fd969d9..91650ac59d 100644 --- a/src/pages/conversations.jsx +++ b/src/pages/conversations.jsx @@ -1,6 +1,7 @@ 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'; @@ -25,13 +26,15 @@ function Conversations({instance}) { setUIState('loading'); (async () => { try { - const conversationsRaw = await masto.v1.conversations.list({ - limit: LIMIT - }); + 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) + u.accounts.forEach((account) => states.accounts[account.id] = account) const conversation = { actors: u.accounts.map(account => account.id), id: u.lastStatus?.id, @@ -39,7 +42,7 @@ function Conversations({instance}) { } const withSameActorsIndex = cc.findIndex(c => c.actors.join(',') == conversation.actors.join(',')); if (withSameActorsIndex == -1) { - cc.push(conversation) + cc.push(conversation) } else if (cc[withSameActorsIndex].date < conversation.date) { cc.set(withSameActorsIndex, conversation) } From cd4e92a098c1aa51f5a4416e7fcc704d47eaff98 Mon Sep 17 00:00:00 2001 From: Matthieu Rakotojaona Date: Mon, 11 Dec 2023 10:56:32 +0100 Subject: [PATCH 6/6] Add github pages action --- .github/workflows/deploy-pages.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/deploy-pages.yml 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 + +