116
src/actions/circle.ts
Normal file
116
src/actions/circle.ts
Normal file
@ -0,0 +1,116 @@
|
||||
// Loosely adapted from twitter-interaction-circles, licensed under MIT License
|
||||
// https://github.com/duiker101/twitter-interaction-circles
|
||||
import api, { getNextLink } from 'soapbox/api';
|
||||
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface Interaction {
|
||||
acct: string;
|
||||
avatar?: string;
|
||||
replies: number;
|
||||
reblogs: number;
|
||||
favourites: number;
|
||||
}
|
||||
|
||||
const processCircle = (setProgress: (progress: {
|
||||
state: 'pending' | 'fetchingStatuses' | 'fetchingFavourites' | 'fetchingAvatars' | 'drawing' | 'done';
|
||||
progress: number;
|
||||
}) => void) =>
|
||||
async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
setProgress({ state: 'pending', progress: 0 });
|
||||
|
||||
const fetch = api(getState);
|
||||
const me = getState().me;
|
||||
|
||||
const interactions: Record<string, Interaction> = {};
|
||||
|
||||
const initInteraction = (id: string) => {
|
||||
if (interactions[id]) return;
|
||||
interactions[id] = {
|
||||
acct: '',
|
||||
replies: 0,
|
||||
reblogs: 0,
|
||||
favourites: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const fetchStatuses = async (url = `/api/v1/accounts/${me}/statuses?with_muted=true&limit=40`) => {
|
||||
const response = await fetch<APIEntity[]>(url);
|
||||
const next = getNextLink(response);
|
||||
|
||||
response.json.forEach((status) => {
|
||||
if (status.reblog) {
|
||||
if (status.reblog.account.id === me) return;
|
||||
|
||||
initInteraction(status.reblog.account.id);
|
||||
const interaction = interactions[status.reblog.account.id];
|
||||
|
||||
interaction.reblogs += 1;
|
||||
interaction.acct = status.reblog.account.acct;
|
||||
interaction.avatar = status.reblog.account.avatar_static || status.reblog.account.avatar;
|
||||
} else if (status.in_reply_to_account_id) {
|
||||
if (status.in_reply_to_account_id === me) return;
|
||||
|
||||
initInteraction(status.in_reply_to_account_id);
|
||||
const interaction = interactions[status.in_reply_to_account_id];
|
||||
|
||||
interaction.replies += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const fetchFavourites = async (url = '/api/v1/favourites?limit=40') => {
|
||||
const response = await fetch<APIEntity[]>(url);
|
||||
const next = getNextLink(response);
|
||||
|
||||
response.json.forEach((status) => {
|
||||
if (status.account.id === me) return;
|
||||
|
||||
initInteraction(status.account.id);
|
||||
const interaction = interactions[status.account.id];
|
||||
|
||||
interaction.favourites += 1;
|
||||
interaction.acct = status.account.acct;
|
||||
interaction.avatar = status.account.avatar_static || status.account.avatar;
|
||||
});
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
for (let link: string | undefined, i = 0; i < 20; i++) {
|
||||
link = await fetchStatuses(link);
|
||||
setProgress({ state: 'fetchingStatuses', progress: (i / 20) * 40 });
|
||||
if (!link) break;
|
||||
}
|
||||
|
||||
for (let link: string | undefined, i = 0; i < 20; i++) {
|
||||
link = await fetchFavourites(link);
|
||||
setProgress({ state: 'fetchingFavourites', progress: 40 + (i / 20) * 40 });
|
||||
if (!link) break;
|
||||
}
|
||||
|
||||
const result = await Promise.all(Object.entries(interactions).map(([id, { acct, avatar, favourites, reblogs, replies }]) => {
|
||||
const score = favourites + replies * 1.1 + reblogs * 1.3;
|
||||
return { id, acct, avatar, score };
|
||||
}).toSorted((a, b) => b.score - a.score).slice(0, 49).map(async (interaction, index, array) => {
|
||||
setProgress({ state: 'fetchingAvatars', progress: 80 + (index / array.length) * 10 });
|
||||
|
||||
if (interaction.acct) return interaction;
|
||||
|
||||
const { json: account } = await fetch<APIEntity>(`/api/v1/accounts/${interaction.id}`);
|
||||
|
||||
interaction.acct = account.acct;
|
||||
interaction.avatar = account.avatar_static || account.avatar;
|
||||
|
||||
return interaction;
|
||||
}));
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export {
|
||||
processCircle,
|
||||
};
|
||||
@ -49,65 +49,64 @@ const uploadFile = (
|
||||
onFail: (error: unknown) => void = () => {},
|
||||
onProgress: (e: ProgressEvent) => void = () => {},
|
||||
changeTotal: (value: number) => void = () => {},
|
||||
) =>
|
||||
async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
const maxImageSize = getState().instance.configuration.media_attachments.image_size_limit;
|
||||
const maxVideoSize = getState().instance.configuration.media_attachments.video_size_limit;
|
||||
const maxVideoDuration = getState().instance.configuration.media_attachments.video_duration_limit;
|
||||
) => async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
const maxImageSize = getState().instance.configuration.media_attachments.image_size_limit;
|
||||
const maxVideoSize = getState().instance.configuration.media_attachments.video_size_limit;
|
||||
const maxVideoDuration = getState().instance.configuration.media_attachments.video_duration_limit;
|
||||
|
||||
const isImage = file.type.match(/image.*/);
|
||||
const isVideo = file.type.match(/video.*/);
|
||||
const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(file) : 0;
|
||||
const isImage = file.type.match(/image.*/);
|
||||
const isVideo = file.type.match(/video.*/);
|
||||
const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(file) : 0;
|
||||
|
||||
if (isImage && maxImageSize && (file.size > maxImageSize)) {
|
||||
const limit = formatBytes(maxImageSize);
|
||||
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
|
||||
toast.error(message);
|
||||
onFail(true);
|
||||
return;
|
||||
} else if (isVideo && maxVideoSize && (file.size > maxVideoSize)) {
|
||||
const limit = formatBytes(maxVideoSize);
|
||||
const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
|
||||
toast.error(message);
|
||||
onFail(true);
|
||||
return;
|
||||
} else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
|
||||
const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
|
||||
toast.error(message);
|
||||
onFail(true);
|
||||
return;
|
||||
}
|
||||
if (isImage && maxImageSize && (file.size > maxImageSize)) {
|
||||
const limit = formatBytes(maxImageSize);
|
||||
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
|
||||
toast.error(message);
|
||||
onFail(true);
|
||||
return;
|
||||
} else if (isVideo && maxVideoSize && (file.size > maxVideoSize)) {
|
||||
const limit = formatBytes(maxVideoSize);
|
||||
const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
|
||||
toast.error(message);
|
||||
onFail(true);
|
||||
return;
|
||||
} else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
|
||||
const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
|
||||
toast.error(message);
|
||||
onFail(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: Don't define const in loop
|
||||
resizeImage(file).then(resized => {
|
||||
const data = new FormData();
|
||||
data.append('file', resized);
|
||||
// Account for disparity in size of original image and resized data
|
||||
changeTotal(resized.size - file.size);
|
||||
// FIXME: Don't define const in loop
|
||||
resizeImage(file).then(resized => {
|
||||
const data = new FormData();
|
||||
data.append('file', resized);
|
||||
// Account for disparity in size of original image and resized data
|
||||
changeTotal(resized.size - file.size);
|
||||
|
||||
return dispatch(uploadMedia(data, onProgress))
|
||||
.then(({ status, json }) => {
|
||||
// If server-side processing of the media attachment has not completed yet,
|
||||
// poll the server until it is, before showing the media attachment as uploaded
|
||||
if (status === 200) {
|
||||
onSuccess(json);
|
||||
} else if (status === 202) {
|
||||
const poll = () => {
|
||||
dispatch(fetchMedia(json.id)).then(({ status, data }) => {
|
||||
if (status === 200) {
|
||||
onSuccess(json);
|
||||
} else if (status === 206) {
|
||||
setTimeout(() => poll(), 1000);
|
||||
}
|
||||
}).catch(error => onFail(error));
|
||||
};
|
||||
return dispatch(uploadMedia(data, onProgress))
|
||||
.then(({ status, json }) => {
|
||||
// If server-side processing of the media attachment has not completed yet,
|
||||
// poll the server until it is, before showing the media attachment as uploaded
|
||||
if (status === 200) {
|
||||
onSuccess(json);
|
||||
} else if (status === 202) {
|
||||
const poll = () => {
|
||||
dispatch(fetchMedia(json.id)).then(({ status, data }) => {
|
||||
if (status === 200) {
|
||||
onSuccess(json);
|
||||
} else if (status === 206) {
|
||||
setTimeout(() => poll(), 1000);
|
||||
}
|
||||
}).catch(error => onFail(error));
|
||||
};
|
||||
|
||||
poll();
|
||||
}
|
||||
});
|
||||
}).catch(error => onFail(error));
|
||||
};
|
||||
poll();
|
||||
}
|
||||
});
|
||||
}).catch(error => onFail(error));
|
||||
};
|
||||
|
||||
export {
|
||||
fetchMedia,
|
||||
|
||||
180
src/features/circle/index.tsx
Normal file
180
src/features/circle/index.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { processCircle } from 'soapbox/actions/circle';
|
||||
import { resetCompose, uploadComposeSuccess, uploadFile } from 'soapbox/actions/compose';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Accordion, Avatar, Button, Column, HStack, ProgressBar, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
const toRad = (x: number) => x * (Math.PI / 180);
|
||||
|
||||
const avatarMissing = require('soapbox/assets/images/avatar-missing.png');
|
||||
|
||||
const HEIGHT = 1000;
|
||||
const WIDTH = 1000;
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.circle', defaultMessage: 'Interactions circle' },
|
||||
pending: { id: 'interactions_circle.state.pending', defaultMessage: 'Fetching interactions' },
|
||||
fetchingStatuses: { id: 'interactions_circle.state.fetching_statuses', defaultMessage: 'Fetching posts' },
|
||||
fetchingFavourites: { id: 'interactions_circle.state.fetching_favourites', defaultMessage: 'Fetching likes' },
|
||||
fetchingAvatars: { id: 'interactions_circle.state.fetching_avatars', defaultMessage: 'Fetching avatars' },
|
||||
drawing: { id: 'interactions_circle.state.drawing', defaultMessage: 'Drawing circle' },
|
||||
done: { id: 'interactions_circle.state.done', defaultMessage: 'Drawing circle' },
|
||||
});
|
||||
|
||||
const Circle: React.FC = () => {
|
||||
const [{ state, progress }, setProgress] = useState<{
|
||||
state: 'pending' | 'fetchingStatuses' | 'fetchingFavourites' | 'fetchingAvatars' | 'drawing' | 'done';
|
||||
progress: number;
|
||||
}>({ state: 'pending', progress: 0 });
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [users, setUsers] = useState<Array<{ id: string; avatar?: string; acct: string }>>();
|
||||
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const { account } = useOwnAccount();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(processCircle(setProgress)).then(async (users) => {
|
||||
setUsers(users);
|
||||
|
||||
// Adapted from twitter-interaction-circles, licensed under MIT License
|
||||
// https://github.com/duiker101/twitter-interaction-circles
|
||||
const ctx = canvasRef.current?.getContext('2d')!;
|
||||
|
||||
// ctx.fillStyle = '#C5EDCE';
|
||||
// ctx.fillRect(0, 0, 1000, 1000);
|
||||
|
||||
for (const layer of [
|
||||
{ index: 0, off: 0, distance: 0, count: 1, radius: 110, users: [{ avatar: account?.avatar || avatarMissing }] },
|
||||
{ index: 1, off: 1, distance: 200, count: 8, radius: 64, users: users.slice(0, 8) },
|
||||
{ index: 2, off: 9, distance: 330, count: 15, radius: 58, users: users.slice(8, 23) },
|
||||
{ index: 3, off: 24, distance: 450, count: 26, radius: 50, users: users.slice(23, 49) },
|
||||
]) {
|
||||
const { index, off, count, radius, distance, users } = layer;
|
||||
|
||||
const angleSize = 360 / count;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
setProgress({ state: 'drawing', progress: 90 + (i + off) / users.length * 10 });
|
||||
|
||||
const offset = index * 30;
|
||||
|
||||
const r = toRad(i * angleSize + offset);
|
||||
|
||||
const centerX = Math.cos(r) * distance + WIDTH / 2;
|
||||
const centerY = Math.sin(r) * distance + HEIGHT / 2;
|
||||
|
||||
if (!users[i]) break;
|
||||
|
||||
const avatarUrl = users[i].avatar || avatarMissing;
|
||||
|
||||
await new Promise(resolve => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
|
||||
ctx.drawImage(img, centerX - radius, centerY - radius, radius * 2, radius * 2);
|
||||
ctx.restore();
|
||||
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
img.setAttribute('crossorigin', 'anonymous');
|
||||
img.src = avatarUrl;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setProgress({ state: 'done', progress: 100 });
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const onSave: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const fileToDownload = document.createElement('a');
|
||||
fileToDownload.download = 'interactions_circle.png';
|
||||
fileToDownload.href = canvasRef.current!.toDataURL('image/png');
|
||||
fileToDownload.click();
|
||||
};
|
||||
|
||||
const onCompose: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
dispatch(resetCompose('compose-modal'));
|
||||
|
||||
canvasRef.current!.toBlob((blob) => {
|
||||
const file = new File([blob!], 'interactions_circle.png', { type: 'image/png' });
|
||||
|
||||
dispatch(uploadFile(file, intl, (data) => {
|
||||
dispatch(uploadComposeSuccess('compose-modal', data, file));
|
||||
dispatch(openModal('COMPOSE'));
|
||||
}));
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Stack alignItems='center' space={6}>
|
||||
{state !== 'done' && (
|
||||
<Stack
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
className='absolute inset-0 z-40 w-full bg-gray-800/75 p-4 backdrop-blur-lg'
|
||||
space={4}
|
||||
>
|
||||
<ProgressBar progress={progress / 100} size='md' />
|
||||
<Text theme='white' weight='semibold'>
|
||||
{intl.formatMessage(messages[state])}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<canvas className='max-w-full' ref={canvasRef} width={1000} height={1000} />
|
||||
|
||||
<div className='w-full'>
|
||||
<Accordion
|
||||
headline={<FormattedMessage id='interactions_circle.user_list' defaultMessage='User list' />}
|
||||
expanded={expanded}
|
||||
onToggle={setExpanded}
|
||||
>
|
||||
<Stack space={2}>
|
||||
{users?.map(user => (
|
||||
<Link key={user.id} to={`/@${user.acct}`}>
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Avatar size={20} src={user.avatar!} />
|
||||
<Text size='sm' weight='semibold' truncate>
|
||||
{user.acct}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Link>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<HStack space={2}>
|
||||
<Button onClick={onSave} icon={require('@tabler/icons/outline/download.svg')}>
|
||||
<FormattedMessage id='interactions_circle.download' defaultMessage='Download' />
|
||||
</Button>
|
||||
<Button onClick={onCompose} icon={require('@tabler/icons/outline/pencil-plus.svg')}>
|
||||
<FormattedMessage id='interactions_circle.compose' defaultMessage='Share' />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export { Circle as default };
|
||||
@ -129,6 +129,7 @@ import {
|
||||
Relays,
|
||||
Rules,
|
||||
DraftStatuses,
|
||||
Circle,
|
||||
} from './util/async-components';
|
||||
import GlobalHotkeys from './util/global-hotkeys';
|
||||
import { WrappedRoute } from './util/react-router-helpers';
|
||||
@ -286,6 +287,8 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
||||
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
|
||||
<WrappedRoute path='/draft_statuses' page={DefaultPage} component={DraftStatuses} content={children} />
|
||||
|
||||
<WrappedRoute path='/circle' page={DefaultPage} component={Circle} content={children} />
|
||||
|
||||
<WrappedRoute path='/settings/profile' page={DefaultPage} component={EditProfile} content={children} />
|
||||
{features.exportData && <WrappedRoute path='/settings/export' page={DefaultPage} component={ExportData} content={children} />}
|
||||
{features.importData && <WrappedRoute path='/settings/import' page={DefaultPage} component={ImportData} content={children} />}
|
||||
|
||||
@ -159,3 +159,4 @@ export const Relays = lazy(() => import('soapbox/features/admin/relays'));
|
||||
export const Rules = lazy(() => import('soapbox/features/admin/rules'));
|
||||
export const EditRuleModal = lazy(() => import('soapbox/features/ui/components/modals/edit-rule-modal'));
|
||||
export const DraftStatuses = lazy(() => import('soapbox/features/draft-statuses'));
|
||||
export const Circle = lazy(() => import('soapbox/features/circle'));
|
||||
|
||||
@ -328,6 +328,7 @@
|
||||
"column.blocks": "Blocks",
|
||||
"column.bookmarks": "Bookmarks",
|
||||
"column.chats": "Chats",
|
||||
"column.circle": "Interactions circle",
|
||||
"column.community": "Local timeline",
|
||||
"column.crypto_donate": "Donate Cryptocurrency",
|
||||
"column.developers": "Developers",
|
||||
@ -883,6 +884,15 @@
|
||||
"input.copy": "Copy",
|
||||
"input.password.hide_password": "Hide password",
|
||||
"input.password.show_password": "Show password",
|
||||
"interactions_circle.compose": "Share",
|
||||
"interactions_circle.download": "Download",
|
||||
"interactions_circle.state.done": "Drawing circle",
|
||||
"interactions_circle.state.drawing": "Drawing circle",
|
||||
"interactions_circle.state.fetching_avatars": "Fetching avatars",
|
||||
"interactions_circle.state.fetching_favourites": "Fetching likes",
|
||||
"interactions_circle.state.fetching_statuses": "Fetching posts",
|
||||
"interactions_circle.state.pending": "Fetching interactions",
|
||||
"interactions_circle.user_list": "User list",
|
||||
"intervals.full.days": "{number, plural, one {# day} other {# days}}",
|
||||
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
|
||||
|
||||
Reference in New Issue
Block a user