Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create a Conversations section #354

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -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


6 changes: 6 additions & 0 deletions src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -416,6 +418,10 @@ function SecondaryRoutes({ isLoggedIn }) {
<Route path=":id" element={<List />} />
</Route>
<Route path="/ft" element={<FollowedHashtags />} />
<Route path="/c">
<Route index element={<Conversations />} />
<Route path=":id" element={<Conversation />} />
</Route>
</>
)}
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
Expand Down
1 change: 1 addition & 0 deletions src/components/icon.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 3 additions & 0 deletions src/components/nav-menu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ function NavMenu(props) {
</sup>
)}
</MenuLink>
<MenuLink to="/c">
<Icon icon="chat" size="l" /> <span>Conversations</span>
</MenuLink>
<MenuDivider />
<MenuLink to="/l">
<Icon icon="list" size="l" /> <span>Lists</span>
Expand Down
5 changes: 5 additions & 0 deletions src/pages/conversation.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.participants {
display: flex;
padding: 10px;
justify-content: center;
}
267 changes: 267 additions & 0 deletions src/pages/conversation.jsx
Original file line number Diff line number Diff line change
@@ -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
? <div class="participants">
{participants.map((participant) => <AccountBlock account={participant}/>)}
</div>
: "";

return (
<Timeline
key={id}
title="Conversation"
timelineStart={participantsHeader}
id="conversation"
emptyText="This conversation doesn't exist"
errorText="Unable to load conversation."
instance={instance}
fetchItems={fetchItems}
checkForUpdates={checkForUpdates}
useItemID
/>
);
}

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;
4 changes: 4 additions & 0 deletions src/pages/conversations.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.row {
display: flex;
align-items: center;
}
Loading