From 948f272e37b8d254b5fcf9e36c51c03a9bea0723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 13 Jun 2024 23:39:54 +0200 Subject: [PATCH] Interaction circles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/actions/circle.ts | 116 +++++++++++++++ src/actions/media.ts | 107 +++++++------- src/features/circle/index.tsx | 180 +++++++++++++++++++++++ src/features/ui/index.tsx | 3 + src/features/ui/util/async-components.ts | 1 + src/locales/en.json | 10 ++ 6 files changed, 363 insertions(+), 54 deletions(-) create mode 100644 src/actions/circle.ts create mode 100644 src/features/circle/index.tsx diff --git a/src/actions/circle.ts b/src/actions/circle.ts new file mode 100644 index 000000000..8bd0e0092 --- /dev/null +++ b/src/actions/circle.ts @@ -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 = {}; + + 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(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(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(`/api/v1/accounts/${interaction.id}`); + + interaction.acct = account.acct; + interaction.avatar = account.avatar_static || account.avatar; + + return interaction; + })); + + return result; + }; + +export { + processCircle, +}; diff --git a/src/actions/media.ts b/src/actions/media.ts index e0c818000..45c23ee45 100644 --- a/src/actions/media.ts +++ b/src/actions/media.ts @@ -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, diff --git a/src/features/circle/index.tsx b/src/features/circle/index.tsx new file mode 100644 index 000000000..d0bdfa696 --- /dev/null +++ b/src/features/circle/index.tsx @@ -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>(); + + const intl = useIntl(); + const dispatch = useAppDispatch(); + const canvasRef = useRef(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 = (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 = (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 ( + + + {state !== 'done' && ( + + + + {intl.formatMessage(messages[state])} + + + )} + + + +
+ } + expanded={expanded} + onToggle={setExpanded} + > + + {users?.map(user => ( + + + + + {user.acct} + + + + ))} + + +
+ + + + + +
+
+ ); +}; + +export { Circle as default }; diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index d8e9ce7b5..1ce9b2ee1 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -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 = ({ children }) => {features.scheduledStatuses && } + + {features.exportData && } {features.importData && } diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 3dc4f6248..ed5efbc5e 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -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')); diff --git a/src/locales/en.json b/src/locales/en.json index 8c04f04db..153fc78f1 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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}}",