bubble timeline feature
This commit is contained in:
parent
d6ac1b0129
commit
b5038da7dd
12 changed files with 277 additions and 9 deletions
|
|
@ -22,7 +22,7 @@ import {
|
|||
fillHomeTimelineGaps,
|
||||
fillPublicTimelineGaps,
|
||||
fillCommunityTimelineGaps,
|
||||
fillListTimelineGaps,
|
||||
fillListTimelineGaps, fillBubbleTimelineGaps
|
||||
} from './timelines';
|
||||
|
||||
/**
|
||||
|
|
@ -157,6 +157,12 @@ export const connectUserStream = () =>
|
|||
export const connectCommunityStream = ({ onlyMedia } = {}) =>
|
||||
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) });
|
||||
|
||||
/**
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
export const connectBubbleStream = () =>
|
||||
connectTimelineStream(`bubble`, `public:bubble`, {}, { fillGaps: () => (fillBubbleTimelineGaps()) });
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {boolean} [options.onlyMedia]
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) {
|
|||
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
|
||||
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }, done);
|
||||
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
|
||||
export const expandBubbleTimeline = ({ maxId } = {}, done = noOp) => expandTimeline(`bubble`, '/api/v1/timelines/bubble', { local: true, max_id: maxId }, done);
|
||||
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
|
||||
export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId });
|
||||
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
|
||||
|
|
@ -184,6 +185,7 @@ export const expandHashtagTimeline = (hashtag, { maxId, tags, local } =
|
|||
export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home', '/api/v1/timelines/home', {}, done);
|
||||
export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia, allow_local_only: !!allowLocalOnly }, done);
|
||||
export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done);
|
||||
export const fillBubbleTimelineGaps = ({} = {}, done = noOp) => fillTimelineGaps(`bubble`, '/api/v1/timelines/bubble', { local: true }, done);
|
||||
export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done);
|
||||
|
||||
export function expandTimelineRequest(timeline, isLoadingMore) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import SettingText from 'flavours/glitch/components/setting_text';
|
||||
import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle';
|
||||
|
||||
const messages = defineMessages({
|
||||
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
|
||||
settings: { id: 'home.settings', defaultMessage: 'Column settings' },
|
||||
});
|
||||
|
||||
class ColumnSettings extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { settings, onChange, intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className='column-settings'>
|
||||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(ColumnSettings);
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeColumnParams } from '../../../actions/columns';
|
||||
import { changeSetting } from '../../../actions/settings';
|
||||
import ColumnSettings from '../components/column_settings';
|
||||
|
||||
const mapStateToProps = (state, { columnId }) => {
|
||||
const uuid = columnId;
|
||||
const columns = state.getIn(['settings', 'columns']);
|
||||
const index = columns.findIndex(c => c.get('uuid') === uuid);
|
||||
|
||||
return {
|
||||
settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'bubble']),
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { columnId }) => {
|
||||
return {
|
||||
onChange (key, checked) {
|
||||
if (columnId) {
|
||||
dispatch(changeColumnParams(columnId, key, checked));
|
||||
} else {
|
||||
dispatch(changeSetting(['bubble', ...key], checked));
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import BubbleChartIcon from '@/material-icons/400-24px/bubble-chart.svg?react';
|
||||
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { domain } from 'flavours/glitch/initial_state';
|
||||
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { connectBubbleStream } from '../../actions/streaming';
|
||||
import { expandBubbleTimeline } from '../../actions/timelines';
|
||||
import Column from '../../components/column';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.bubble', defaultMessage: 'Bubble timeline' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { columnId }) => {
|
||||
const uuid = columnId;
|
||||
const columns = state.getIn(['settings', 'columns']);
|
||||
const index = columns.findIndex(c => c.get('uuid') === uuid);
|
||||
const regex = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'regex', 'body']) : state.getIn(['settings', 'bubble', 'regex', 'body']);
|
||||
const timelineState = state.getIn(['timelines', `bubble`]);
|
||||
|
||||
return {
|
||||
hasUnread: !!timelineState && timelineState.get('unread') > 0,
|
||||
regex,
|
||||
};
|
||||
};
|
||||
|
||||
class BubbleTimeline extends PureComponent {
|
||||
static defaultProps = {};
|
||||
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hasUnread: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
regex: PropTypes.string,
|
||||
};
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('BUBBLE'));
|
||||
}
|
||||
};
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
dispatch(expandBubbleTimeline());
|
||||
|
||||
if (signedIn) {
|
||||
this.disconnect = dispatch(connectBubbleStream());
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
if (prevProps !== this.props) {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (this.disconnect) {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
dispatch(expandBubbleTimeline());
|
||||
|
||||
if (signedIn) {
|
||||
this.disconnect = dispatch(connectBubbleStream());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.disconnect) {
|
||||
this.disconnect();
|
||||
this.disconnect = null;
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleLoadMore = maxId => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(expandBubbleTimeline({ maxId }));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, hasUnread, columnId, multiColumn } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon='users'
|
||||
iconComponent={BubbleChartIcon}
|
||||
active={hasUnread}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
>
|
||||
<ColumnSettingsContainer columnId={columnId} />
|
||||
</ColumnHeader>
|
||||
|
||||
<StatusListContainer
|
||||
prepend={<DismissableBanner id='bubble_timeline'><FormattedMessage id='dismissable_banner.bubble_timeline' defaultMessage='These are posts from your local instance and others chosen by the administrators of {domain}.' values={{ domain }} /></DismissableBanner>}
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`bubble_timeline-${columnId}`}
|
||||
timelineId={`bubble`}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.bubble' defaultMessage='The bubble timeline is empty. Write something publicly to get the ball rolling!' />}
|
||||
bindToDocument={!multiColumn}
|
||||
regex={this.props.regex}
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withIdentity(connect(mapStateToProps)(injectIntl(BubbleTimeline)));
|
||||
|
|
@ -10,8 +10,8 @@ import { useIdentity } from '@/flavours/glitch/identity_context';
|
|||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import { addColumn } from 'flavours/glitch/actions/columns';
|
||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||
import { connectPublicStream, connectCommunityStream } from 'flavours/glitch/actions/streaming';
|
||||
import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
|
||||
import { connectPublicStream, connectCommunityStream, connectBubbleStream } from 'flavours/glitch/actions/streaming';
|
||||
import { expandPublicTimeline, expandCommunityTimeline, expandBubbleTimeline } from 'flavours/glitch/actions/timelines';
|
||||
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
|
||||
import SettingText from 'flavours/glitch/components/setting_text';
|
||||
import { domain } from 'flavours/glitch/initial_state';
|
||||
|
|
@ -90,6 +90,9 @@ const Firehose = ({ feedType, multiColumn }) => {
|
|||
case 'community':
|
||||
dispatch(addColumn('COMMUNITY', { other: { onlyMedia }, regex: { body: regex } }));
|
||||
break;
|
||||
case 'bubble':
|
||||
dispatch(addColumn('BUBBLE', { other: { onlyMedia }, regex: { body: regex } }));
|
||||
break;
|
||||
case 'public':
|
||||
dispatch(addColumn('PUBLIC', { other: { onlyMedia, allowLocalOnly }, regex: { body: regex } }));
|
||||
break;
|
||||
|
|
@ -107,6 +110,9 @@ const Firehose = ({ feedType, multiColumn }) => {
|
|||
case 'community':
|
||||
dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
|
||||
break;
|
||||
case 'bubble':
|
||||
dispatch(expandBubbleTimeline({ maxId, onlyMedia }));
|
||||
break;
|
||||
case 'public':
|
||||
dispatch(expandPublicTimeline({ maxId, onlyMedia, allowLocalOnly }));
|
||||
break;
|
||||
|
|
@ -130,6 +136,12 @@ const Firehose = ({ feedType, multiColumn }) => {
|
|||
disconnect = dispatch(connectCommunityStream({ onlyMedia }));
|
||||
}
|
||||
break;
|
||||
case 'bubble':
|
||||
dispatch(expandBubbleTimeline());
|
||||
if (signedIn) {
|
||||
disconnect = dispatch(connectBubbleStream());
|
||||
}
|
||||
break;
|
||||
case 'public':
|
||||
dispatch(expandPublicTimeline({ onlyMedia, allowLocalOnly }));
|
||||
if (signedIn) {
|
||||
|
|
@ -147,6 +159,7 @@ const Firehose = ({ feedType, multiColumn }) => {
|
|||
return () => disconnect?.();
|
||||
}, [dispatch, signedIn, feedType, onlyMedia, allowLocalOnly]);
|
||||
|
||||
// todo bubble
|
||||
const prependBanner = feedType === 'community' ? (
|
||||
<DismissableBanner id='community_timeline'>
|
||||
<FormattedMessage
|
||||
|
|
@ -165,7 +178,7 @@ const Firehose = ({ feedType, multiColumn }) => {
|
|||
</DismissableBanner>
|
||||
);
|
||||
|
||||
const emptyMessage = feedType === 'community' ? (
|
||||
const emptyMessage = feedType === 'community' || feedType === 'bubble' ? (
|
||||
<FormattedMessage
|
||||
id='empty_column.community'
|
||||
defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!'
|
||||
|
|
@ -196,6 +209,10 @@ const Firehose = ({ feedType, multiColumn }) => {
|
|||
<FormattedMessage tagName='div' id='firehose.local' defaultMessage='This server' />
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact to='/public/bubble'>
|
||||
<FormattedMessage tagName='div' id='firehose.bubble' defaultMessage='Bubble' />
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact to='/public/remote'>
|
||||
<FormattedMessage tagName='div' id='firehose.remote' defaultMessage='Other servers' />
|
||||
</NavLink>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import BubbleChartIcon from '@/material-icons/400-24px/bubble-chart.svg?react';
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
|
|
@ -47,6 +48,7 @@ const messages = defineMessages({
|
|||
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
|
||||
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
|
||||
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
|
||||
bubble_timeline: { id: 'navigation_bar.bubble_timeline', defaultMessage: 'Bubble timeline' },
|
||||
explore: { id: 'navigation_bar.explore', defaultMessage: 'Explore' },
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
|
|
@ -149,6 +151,10 @@ class GettingStarted extends ImmutablePureComponent {
|
|||
navItems.push(<ColumnLink key='community_timeline' icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.community_timeline)} to='/public/local' />);
|
||||
}
|
||||
|
||||
if (!columns.find(item => item.get('id') === 'BUBBLE')) {
|
||||
navItems.push(<ColumnLink key='bubble_timeline' icon='bubble-chart' iconComponent={BubbleChartIcon} text={intl.formatMessage(messages.bubble_timeline)} to='/public/bubble' />);
|
||||
}
|
||||
|
||||
if (!columns.find(item => item.get('id') === 'PUBLIC')) {
|
||||
navItems.push(<ColumnLink key='public_timeline' icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.public_timeline)} to='/public' />);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
<Redirect from='/timelines/public/local' to='/public/local' exact />
|
||||
<WrappedRoute path='/public' exact component={Firehose} componentParams={{ feedType: 'public' }} content={children} />
|
||||
<WrappedRoute path='/public/local' exact component={Firehose} componentParams={{ feedType: 'community' }} content={children} />
|
||||
<WrappedRoute path='/public/bubble' exact component={Firehose} componentParams={{ feedType: 'bubble' }} content={children} />
|
||||
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
|
||||
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
|
||||
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ export function CommunityTimeline () {
|
|||
return import(/* webpackChunkName: "flavours/glitch/async/community_timeline" */'../../community_timeline');
|
||||
}
|
||||
|
||||
export function BubbleTimeline () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/community_timeline" */'../../bubble_timeline');
|
||||
}
|
||||
|
||||
export function Firehose () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/firehose" */'../../firehose');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ const initialState = ImmutableMap({
|
|||
stretch : true,
|
||||
side_arm : 'none',
|
||||
side_arm_reply_mode : 'keep',
|
||||
show_reply_count : false,
|
||||
always_show_spoilers_field: false,
|
||||
show_reply_count : true,
|
||||
always_show_spoilers_field: true,
|
||||
confirm_missing_media_description: false,
|
||||
confirm_boost_missing_media_description: false,
|
||||
confirm_before_clearing_draft: true,
|
||||
prepend_cw_re: false,
|
||||
prepend_cw_re: true,
|
||||
preselect_on_reply: false,
|
||||
inline_preview_cards: true,
|
||||
hicolor_privacy_icons: false,
|
||||
|
|
|
|||
|
|
@ -97,10 +97,11 @@ a:focus-visible,
|
|||
background: none;
|
||||
}
|
||||
.account__avatar,
|
||||
.account__avatar img,
|
||||
#profile_page_avatar,
|
||||
.account__avatar-composite,
|
||||
.account-card__title__avatar img {
|
||||
border-radius: 30%;
|
||||
border-radius: 25%;
|
||||
flex: none;
|
||||
}
|
||||
:not(body):not(.scrollable)::-webkit-scrollbar {
|
||||
|
|
@ -3258,4 +3259,4 @@ a:focus-visible,
|
|||
}
|
||||
.emoji-picker-dropdown__modifiers {
|
||||
top: 16px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
app/javascript/material-icons/400-24px/bubble-chart.svg
Normal file
1
app/javascript/material-icons/400-24px/bubble-chart.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M580-120q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T620-240q0-17-11.5-28.5T580-280q-17 0-28.5 11.5T540-240q0 17 11.5 28.5T580-200Zm80-200q-92 0-156-64t-64-156q0-92 64-156t156-64q92 0 156 64t64 156q0 92-64 156t-156 64Zm0-80q59 0 99.5-40.5T800-620q0-59-40.5-99.5T660-760q-59 0-99.5 40.5T520-620q0 59 40.5 99.5T660-480ZM280-240q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47Zm0-80q33 0 56.5-23.5T360-400q0-33-23.5-56.5T280-480q-33 0-56.5 23.5T200-400q0 33 23.5 56.5T280-320Zm300 80Zm80-380ZM280-400Z"/></svg>
|
||||
|
After Width: | Height: | Size: 702 B |
Loading…
Add table
Add a link
Reference in a new issue