mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Merge pull request #3348 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to bc2f8a358f
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
<html class="no-reduce-motion theme-light">
|
||||
<html class="no-reduce-motion" data-color-scheme="light">
|
||||
</html>
|
||||
14
Gemfile
14
Gemfile
@@ -55,7 +55,7 @@ gem 'hiredis-client'
|
||||
gem 'htmlentities', '~> 4.3'
|
||||
gem 'http', '~> 5.3.0'
|
||||
gem 'http_accept_language', '~> 2.1'
|
||||
gem 'httplog', '~> 1.7.0', require: false
|
||||
gem 'httplog', '~> 1.8.0', require: false
|
||||
gem 'i18n'
|
||||
gem 'idn-ruby', require: 'idn'
|
||||
gem 'inline_svg'
|
||||
@@ -109,12 +109,12 @@ group :opentelemetry do
|
||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.30.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.34.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.31.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.28.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.35.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
|
||||
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
|
||||
|
||||
32
Gemfile.lock
32
Gemfile.lock
@@ -320,7 +320,8 @@ GEM
|
||||
http_accept_language (2.1.1)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
httplog (1.7.3)
|
||||
httplog (1.8.0)
|
||||
benchmark
|
||||
rack (>= 2.0)
|
||||
rainbow (>= 2.0.0)
|
||||
i18n (1.14.8)
|
||||
@@ -520,7 +521,8 @@ GEM
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-helpers-sql (0.3.0)
|
||||
opentelemetry-api (~> 1.7)
|
||||
opentelemetry-helpers-sql-processor (0.3.1)
|
||||
opentelemetry-helpers-sql-processor (0.4.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-instrumentation-action_mailer (0.6.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
@@ -544,17 +546,17 @@ GEM
|
||||
opentelemetry-registry (~> 0.1)
|
||||
opentelemetry-instrumentation-concurrent_ruby (0.24.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-excon (0.26.1)
|
||||
opentelemetry-instrumentation-excon (0.27.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-faraday (0.30.1)
|
||||
opentelemetry-instrumentation-faraday (0.31.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-http (0.27.1)
|
||||
opentelemetry-instrumentation-http (0.28.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-http_client (0.26.1)
|
||||
opentelemetry-instrumentation-http_client (0.27.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-net_http (0.26.1)
|
||||
opentelemetry-instrumentation-net_http (0.27.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-pg (0.34.1)
|
||||
opentelemetry-instrumentation-pg (0.35.0)
|
||||
opentelemetry-helpers-sql
|
||||
opentelemetry-helpers-sql-processor
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
@@ -985,7 +987,7 @@ DEPENDENCIES
|
||||
htmlentities (~> 4.3)
|
||||
http (~> 5.3.0)
|
||||
http_accept_language (~> 2.1)
|
||||
httplog (~> 1.7.0)
|
||||
httplog (~> 1.8.0)
|
||||
i18n
|
||||
i18n-tasks (~> 1.0)
|
||||
idn-ruby
|
||||
@@ -1021,12 +1023,12 @@ DEPENDENCIES
|
||||
opentelemetry-instrumentation-active_job (~> 0.10.0)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.24.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.26.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.30.0)
|
||||
opentelemetry-instrumentation-http (~> 0.27.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.26.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.26.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.34.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.27.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.31.0)
|
||||
opentelemetry-instrumentation-http (~> 0.28.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.27.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.27.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.35.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.29.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.39.0)
|
||||
opentelemetry-instrumentation-redis (~> 0.28.0)
|
||||
|
||||
@@ -18,5 +18,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||
| 4.5.x | Yes |
|
||||
| 4.4.x | Yes |
|
||||
| 4.3.x | Until 2026-05-06 |
|
||||
| 4.2.x | Until 2026-01-08 |
|
||||
| < 4.2 | No |
|
||||
| < 4.3 | No |
|
||||
|
||||
@@ -89,6 +89,12 @@ module ApplicationHelper
|
||||
Rails.env.production? ? site_title : "#{site_title} (Dev)"
|
||||
end
|
||||
|
||||
def page_color_scheme
|
||||
return content_for(:force_color_scheme) if content_for(:force_color_scheme)
|
||||
|
||||
color_scheme
|
||||
end
|
||||
|
||||
def label_for_scope(scope)
|
||||
safe_join [
|
||||
tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }),
|
||||
@@ -153,6 +159,19 @@ module ApplicationHelper
|
||||
tag.meta(content: content, property: property)
|
||||
end
|
||||
|
||||
def html_attributes
|
||||
base = {
|
||||
lang: I18n.locale,
|
||||
class: html_classes,
|
||||
'data-contrast': contrast.parameterize,
|
||||
'data-color-scheme': page_color_scheme.parameterize,
|
||||
}
|
||||
|
||||
base[:'data-system-theme'] = 'true' if page_color_scheme == 'auto'
|
||||
|
||||
base
|
||||
end
|
||||
|
||||
def html_classes
|
||||
output = []
|
||||
output << content_for(:html_classes)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export interface MiniCardProps {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
className?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const MiniCard: FC<MiniCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
hidden,
|
||||
}) => {
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.card, className)}
|
||||
inert={hidden ? '' : undefined}
|
||||
>
|
||||
<dt className={classes.label}>{label}</dt>
|
||||
<dd className={classes.value}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
208
app/javascript/flavours/glitch/components/mini_card/list.tsx
Normal file
208
app/javascript/flavours/glitch/components/mini_card/list.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FC, Key, MouseEventHandler } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { MiniCard } from '.';
|
||||
import type { MiniCardProps } from '.';
|
||||
import classes from './styles.module.css';
|
||||
|
||||
interface MiniCardListProps {
|
||||
cards?: (Pick<MiniCardProps, 'label' | 'value'> & { key?: Key })[];
|
||||
onOverflowClick?: MouseEventHandler;
|
||||
}
|
||||
|
||||
export const MiniCardList: FC<MiniCardListProps> = ({
|
||||
cards = [],
|
||||
onOverflowClick,
|
||||
}) => {
|
||||
const {
|
||||
wrapperRef,
|
||||
listRef,
|
||||
hiddenCount,
|
||||
hasOverflow,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
} = useOverflow();
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper} ref={wrapperRef}>
|
||||
<dl className={classes.list} ref={listRef} style={{ maxWidth }}>
|
||||
{cards.map((card, index) => (
|
||||
<MiniCard
|
||||
key={card.key ?? index}
|
||||
label={card.label}
|
||||
value={card.value}
|
||||
hidden={index >= hiddenIndex}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(classes.more, !hasOverflow && classes.hidden)}
|
||||
onClick={onOverflowClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='minicard.more_items'
|
||||
defaultMessage='+ {count} more'
|
||||
values={{ count: hiddenCount }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useOverflow() {
|
||||
const [hiddenIndex, setHiddenIndex] = useState(-1);
|
||||
const [hiddenCount, setHiddenCount] = useState(0);
|
||||
const [maxWidth, setMaxWidth] = useState<number | 'none'>('none');
|
||||
|
||||
// This is the item container element.
|
||||
const listRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// The main recalculation function.
|
||||
const handleRecalculate = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
if (!listEle) return;
|
||||
|
||||
const reset = () => {
|
||||
setHiddenIndex(-1);
|
||||
setHiddenCount(0);
|
||||
setMaxWidth('none');
|
||||
};
|
||||
|
||||
// Calculate the width via the parent element, minus the more button, minus the padding.
|
||||
const maxWidth =
|
||||
(listEle.parentElement?.offsetWidth ?? 0) -
|
||||
(listEle.nextElementSibling?.scrollWidth ?? 0) -
|
||||
4;
|
||||
if (maxWidth <= 0) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate through children until we exceed max width.
|
||||
let visible = 0;
|
||||
let index = 0;
|
||||
let totalWidth = 0;
|
||||
for (const child of listEle.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
const rightOffset = child.offsetLeft + child.offsetWidth;
|
||||
if (rightOffset <= maxWidth) {
|
||||
visible += 1;
|
||||
totalWidth = rightOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
// All are visible, so remove max-width restriction.
|
||||
if (visible === listEle.children.length) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the width to avoid wrapping, and set hidden count.
|
||||
setHiddenIndex(index);
|
||||
setHiddenCount(listEle.children.length - visible);
|
||||
setMaxWidth(totalWidth);
|
||||
}, []);
|
||||
|
||||
// Set up observers to watch for size and content changes.
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const mutationObserverRef = useRef<MutationObserver | null>(null);
|
||||
|
||||
// Helper to get or create the resize observer.
|
||||
const resizeObserver = useCallback(() => {
|
||||
const observer = (resizeObserverRef.current ??= new ResizeObserver(
|
||||
handleRecalculate,
|
||||
));
|
||||
return observer;
|
||||
}, [handleRecalculate]);
|
||||
|
||||
// Iterate through children and observe them for size changes.
|
||||
const handleChildrenChange = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
const observer = resizeObserver();
|
||||
|
||||
if (listEle) {
|
||||
for (const child of listEle.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
observer.observe(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleRecalculate();
|
||||
}, [handleRecalculate, resizeObserver]);
|
||||
|
||||
// Helper to get or create the mutation observer.
|
||||
const mutationObserver = useCallback(() => {
|
||||
const observer = (mutationObserverRef.current ??= new MutationObserver(
|
||||
handleChildrenChange,
|
||||
));
|
||||
return observer;
|
||||
}, [handleChildrenChange]);
|
||||
|
||||
// Set up observers.
|
||||
const handleObserve = useCallback(() => {
|
||||
if (wrapperRef.current) {
|
||||
resizeObserver().observe(wrapperRef.current);
|
||||
}
|
||||
if (listRef.current) {
|
||||
mutationObserver().observe(listRef.current, { childList: true });
|
||||
handleChildrenChange();
|
||||
}
|
||||
}, [handleChildrenChange, mutationObserver, resizeObserver]);
|
||||
|
||||
// Watch the wrapper for size changes, and recalculate when it resizes.
|
||||
const wrapperRef = useRef<HTMLElement | null>(null);
|
||||
const wrapperRefCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
wrapperRef.current = node;
|
||||
handleObserve();
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
);
|
||||
|
||||
// If there are changes to the children, recalculate which are visible.
|
||||
const listRefCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
listRef.current = node;
|
||||
handleObserve();
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleObserve();
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
if (mutationObserverRef.current) {
|
||||
mutationObserverRef.current.disconnect();
|
||||
mutationObserverRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [handleObserve]);
|
||||
|
||||
return {
|
||||
hiddenCount,
|
||||
hasOverflow: hiddenCount > 0,
|
||||
wrapperRef: wrapperRefCallback,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
listRef: listRefCallback,
|
||||
recalculate: handleRecalculate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { MiniCardList } from './list';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/MiniCard',
|
||||
component: MiniCardList,
|
||||
args: {
|
||||
cards: [
|
||||
{ label: 'Pronouns', value: 'they/them' },
|
||||
{
|
||||
label: 'Website',
|
||||
value: <a href='https://example.com'>bowie-the-db.meow</a>,
|
||||
},
|
||||
{
|
||||
label: 'Free playlists',
|
||||
value: <a href='https://soundcloud.com/bowie-the-dj'>soundcloud.com</a>,
|
||||
},
|
||||
{ label: 'Location', value: 'Purris, France' },
|
||||
],
|
||||
onOverflowClick: action('Overflow clicked'),
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
resize: 'horizontal',
|
||||
padding: '1rem',
|
||||
border: '1px solid gray',
|
||||
overflow: 'auto',
|
||||
width: '400px',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
<MiniCardList {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof MiniCardList>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const LongValue: Story = {
|
||||
args: {
|
||||
cards: [
|
||||
{
|
||||
label: 'Username',
|
||||
value: 'bowie-the-dj',
|
||||
},
|
||||
{
|
||||
label: 'Bio',
|
||||
value:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.list {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card,
|
||||
.more {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 20vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.more {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
appearance: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.label,
|
||||
.value {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,181 +1,40 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
||||
import { AccountFields } from '@/flavours/glitch/components/account_fields';
|
||||
import { DisplayName } from '@/flavours/glitch/components/display_name';
|
||||
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
import {
|
||||
followAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
pinAccount,
|
||||
unpinAccount,
|
||||
removeAccountFromFollowers,
|
||||
} from 'flavours/glitch/actions/accounts';
|
||||
import { initBlockModal } from 'flavours/glitch/actions/blocks';
|
||||
import { mentionCompose, directCompose } from 'flavours/glitch/actions/compose';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
unblockDomain,
|
||||
} from 'flavours/glitch/actions/domain_blocks';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { initMuteModal } from 'flavours/glitch/actions/mutes';
|
||||
import { initReport } from 'flavours/glitch/actions/reports';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import {
|
||||
Badge,
|
||||
AutomatedBadge,
|
||||
GroupBadge,
|
||||
} from 'flavours/glitch/components/badge';
|
||||
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
|
||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||
import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { AccountNote } from 'flavours/glitch/features/account/components/account_note';
|
||||
import { DomainPill } from 'flavours/glitch/features/account/components/domain_pill';
|
||||
import FollowRequestNoteContainer from 'flavours/glitch/features/account/containers/follow_request_note_container';
|
||||
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||
import {
|
||||
autoPlayGif,
|
||||
me,
|
||||
domain as localDomain,
|
||||
} from 'flavours/glitch/initial_state';
|
||||
import type { Account } from 'flavours/glitch/models/account';
|
||||
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_USERS,
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
} from 'flavours/glitch/permissions';
|
||||
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { ActionBar } from '../../account/components/action_bar';
|
||||
|
||||
import { AccountBadges } from './badges';
|
||||
import { AccountButtons } from './buttons';
|
||||
import { FamiliarFollowers } from './familiar_followers';
|
||||
import { AccountInfo } from './info';
|
||||
import { MemorialNote } from './memorial_note';
|
||||
import { MovedNote } from './moved_note';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
linkVerifiedOn: {
|
||||
id: 'account.link_verified_on',
|
||||
defaultMessage: 'Ownership of this link was checked on {date}',
|
||||
},
|
||||
account_locked: {
|
||||
id: 'account.locked_info',
|
||||
defaultMessage:
|
||||
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
|
||||
},
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
|
||||
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
|
||||
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
|
||||
media: { id: 'account.media', defaultMessage: 'Media' },
|
||||
blockDomain: {
|
||||
id: 'account.block_domain',
|
||||
defaultMessage: 'Block domain {domain}',
|
||||
},
|
||||
unblockDomain: {
|
||||
id: 'account.unblock_domain',
|
||||
defaultMessage: 'Unblock domain {domain}',
|
||||
},
|
||||
hideReblogs: {
|
||||
id: 'account.hide_reblogs',
|
||||
defaultMessage: 'Hide boosts from @{name}',
|
||||
},
|
||||
showReblogs: {
|
||||
id: 'account.show_reblogs',
|
||||
defaultMessage: 'Show boosts from @{name}',
|
||||
},
|
||||
enableNotifications: {
|
||||
id: 'account.enable_notifications',
|
||||
defaultMessage: 'Notify me when @{name} posts',
|
||||
},
|
||||
disableNotifications: {
|
||||
id: 'account.disable_notifications',
|
||||
defaultMessage: 'Stop notifying me when @{name} posts',
|
||||
},
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
follow_requests: {
|
||||
id: 'navigation_bar.follow_requests',
|
||||
defaultMessage: 'Follow requests',
|
||||
},
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: {
|
||||
id: 'navigation_bar.followed_tags',
|
||||
defaultMessage: 'Followed hashtags',
|
||||
},
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: {
|
||||
id: 'navigation_bar.domain_blocks',
|
||||
defaultMessage: 'Blocked domains',
|
||||
},
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
||||
unendorse: {
|
||||
id: 'account.unendorse',
|
||||
defaultMessage: "Don't feature on profile",
|
||||
},
|
||||
add_or_remove_from_list: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
admin_account: {
|
||||
id: 'status.admin_account',
|
||||
defaultMessage: 'Open moderation interface for @{name}',
|
||||
},
|
||||
admin_domain: {
|
||||
id: 'status.admin_domain',
|
||||
defaultMessage: 'Open moderation interface for {domain}',
|
||||
},
|
||||
languages: {
|
||||
id: 'account.languages',
|
||||
defaultMessage: 'Change subscribed languages',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
removeFromFollowers: {
|
||||
id: 'account.remove_from_followers',
|
||||
defaultMessage: 'Remove {name} from followers',
|
||||
},
|
||||
confirmRemoveFromFollowersTitle: {
|
||||
id: 'confirmations.remove_from_followers.title',
|
||||
defaultMessage: 'Remove follower?',
|
||||
},
|
||||
confirmRemoveFromFollowersMessage: {
|
||||
id: 'confirmations.remove_from_followers.message',
|
||||
defaultMessage:
|
||||
'{name} will stop following you. Are you sure you want to proceed?',
|
||||
},
|
||||
confirmRemoveFromFollowersButton: {
|
||||
id: 'confirmations.remove_from_followers.confirm',
|
||||
defaultMessage: 'Remove follower',
|
||||
},
|
||||
});
|
||||
import { AccountTabs } from './tabs';
|
||||
|
||||
const titleFromAccount = (account: Account) => {
|
||||
const displayName = account.display_name;
|
||||
@@ -195,149 +54,12 @@ export const AccountHeader: React.FC<{
|
||||
}> = ({ accountId, hideTabs }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const { signedIn, permissions } = useIdentity();
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
dispatch(unblockAccount(account.id));
|
||||
} else {
|
||||
dispatch(initBlockModal(account));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleMention = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(mentionCompose(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleDirect = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(directCompose(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(initReport(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleReblogToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.showing_reblogs) {
|
||||
dispatch(followAccount(account.id, { reblogs: false }));
|
||||
} else {
|
||||
dispatch(followAccount(account.id, { reblogs: true }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleNotifyToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.notifying) {
|
||||
dispatch(followAccount(account.id, { notify: false }));
|
||||
} else {
|
||||
dispatch(followAccount(account.id, { notify: true }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleBlockDomain = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(initDomainBlockModal(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleUnblockDomain = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = account.acct.split('@')[1];
|
||||
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(unblockDomain(domain));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleEndorseToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleAddToList = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleChangeLanguages = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SUBSCRIBED_LANGUAGES',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleOpenAvatar = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||
@@ -363,410 +85,14 @@ export const AccountHeader: React.FC<{
|
||||
[dispatch, account],
|
||||
);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.share({
|
||||
url: account.url,
|
||||
});
|
||||
}, [account]);
|
||||
|
||||
const suspended = account?.suspended;
|
||||
const isRemote = account?.acct !== account?.username;
|
||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMention,
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.direct, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleDirect,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: account.url,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.hideReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReblogToggle,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.showReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReblogToggle,
|
||||
});
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.languages),
|
||||
action: handleChangeLanguages,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: handleEndorseToggle,
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
action: handleAddToList,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersTitle,
|
||||
),
|
||||
message: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersMessage,
|
||||
{ name: <strong>{account.acct}</strong> },
|
||||
),
|
||||
confirm: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersButton,
|
||||
),
|
||||
onConfirm: () => {
|
||||
void dispatch(removeAccountFromFollowers({ accountId }));
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMute,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMute,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleBlock,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleBlock,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReport,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (signedIn && isRemote) {
|
||||
arr.push(null);
|
||||
|
||||
if (relationship?.domain_blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: handleUnblockDomain,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.blockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: handleBlockDomain,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
) {
|
||||
arr.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [
|
||||
dispatch,
|
||||
accountId,
|
||||
account,
|
||||
relationship,
|
||||
permissions,
|
||||
isRemote,
|
||||
remoteDomain,
|
||||
intl,
|
||||
signedIn,
|
||||
handleAddToList,
|
||||
handleBlock,
|
||||
handleBlockDomain,
|
||||
handleChangeLanguages,
|
||||
handleDirect,
|
||||
handleEndorseToggle,
|
||||
handleMention,
|
||||
handleMute,
|
||||
handleReblogToggle,
|
||||
handleReport,
|
||||
handleUnblockDomain,
|
||||
]);
|
||||
|
||||
const menu = accountId !== me && (
|
||||
<Dropdown
|
||||
disabled={menuItems.length === 0}
|
||||
items={menuItems}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let actionBtn: React.ReactNode,
|
||||
bellBtn: React.ReactNode,
|
||||
lockedIcon: React.ReactNode,
|
||||
shareBtn: React.ReactNode;
|
||||
|
||||
const info: React.ReactNode[] = [];
|
||||
|
||||
if (me !== account.id && relationship) {
|
||||
if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
info.push(
|
||||
<span key='mutual' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
info.push(
|
||||
<span key='followed_by' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
info.push(
|
||||
<span key='requested_by' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.requests_to_follow_you'
|
||||
defaultMessage='Requests to follow you'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.blocking) {
|
||||
info.push(
|
||||
<span key='blocking' className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.muting) {
|
||||
info.push(
|
||||
<span key='muting' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muting' defaultMessage='Muting' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.domain_blocking) {
|
||||
info.push(
|
||||
<span key='domain_blocking' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocking'
|
||||
defaultMessage='Blocking domain'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (relationship?.requested || relationship?.following) {
|
||||
bellBtn = (
|
||||
<IconButton
|
||||
icon={relationship.notifying ? 'bell' : 'bell-o'}
|
||||
iconComponent={
|
||||
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
|
||||
}
|
||||
active={relationship.notifying}
|
||||
title={intl.formatMessage(
|
||||
relationship.notifying
|
||||
? messages.disableNotifications
|
||||
: messages.enableNotifications,
|
||||
{ name: account.username },
|
||||
)}
|
||||
onClick={handleNotifyToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ('share' in navigator) {
|
||||
shareBtn = (
|
||||
<IconButton
|
||||
className='optional'
|
||||
icon=''
|
||||
iconComponent={ShareIcon}
|
||||
title={intl.formatMessage(messages.share, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleShare}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
shareBtn = (
|
||||
<CopyIconButton
|
||||
className='optional'
|
||||
title={intl.formatMessage(messages.copy)}
|
||||
value={account.url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
|
||||
|
||||
if (!isMovedAndUnfollowedAccount) {
|
||||
actionBtn = (
|
||||
<FollowButton
|
||||
accountId={accountId}
|
||||
className='account__header__follow-button'
|
||||
labelLength='long'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (account.locked) {
|
||||
lockedIcon = (
|
||||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
aria-label={intl.formatMessage(messages.account_locked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = account.fields;
|
||||
const suspendedOrHidden = hidden || account.suspended;
|
||||
const isLocal = !account.acct.includes('@');
|
||||
const username = account.acct.split('@')[0];
|
||||
const domain = isLocal ? localDomain : account.acct.split('@')[1];
|
||||
const isIndexable = !account.noindex;
|
||||
|
||||
const badges = [];
|
||||
|
||||
if (account.bot) {
|
||||
badges.push(<AutomatedBadge key='bot-badge' />);
|
||||
} else if (account.group) {
|
||||
badges.push(<GroupBadge key='group-badge' />);
|
||||
}
|
||||
|
||||
account.roles.forEach((role) => {
|
||||
badges.push(
|
||||
<Badge
|
||||
key={`role-badge-${role.get('id')}`}
|
||||
label={<span>{role.get('name')}</span>}
|
||||
domain={domain}
|
||||
roleId={role.get('id')}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='account-timeline__header'>
|
||||
@@ -780,15 +106,16 @@ export const AccountHeader: React.FC<{
|
||||
inactive: !!account.moved,
|
||||
})}
|
||||
>
|
||||
{!(suspended || hidden || account.moved) &&
|
||||
relationship?.requested_by && (
|
||||
<FollowRequestNoteContainer account={account} />
|
||||
)}
|
||||
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
|
||||
<FollowRequestNoteContainer account={account} />
|
||||
)}
|
||||
|
||||
<div className='account__header__image'>
|
||||
<div className='account__header__info'>{info}</div>
|
||||
{me !== account.id && relationship && (
|
||||
<AccountInfo relationship={relationship} />
|
||||
)}
|
||||
|
||||
{!(suspended || hidden) && (
|
||||
{!suspendedOrHidden && (
|
||||
<img
|
||||
src={autoPlayGif ? account.header : account.header_static}
|
||||
alt=''
|
||||
@@ -807,17 +134,15 @@ export const AccountHeader: React.FC<{
|
||||
onClick={handleOpenAvatar}
|
||||
>
|
||||
<Avatar
|
||||
account={suspended || hidden ? undefined : account}
|
||||
account={suspendedOrHidden ? undefined : account}
|
||||
size={92}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className='account__header__buttons account__header__buttons--desktop'>
|
||||
{!hidden && actionBtn}
|
||||
{!hidden && bellBtn}
|
||||
{!hidden && shareBtn}
|
||||
{menu}
|
||||
</div>
|
||||
<AccountButtons
|
||||
accountId={accountId}
|
||||
className='account__header__buttons--desktop'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='account__header__tabs__name'>
|
||||
@@ -833,29 +158,37 @@ export const AccountHeader: React.FC<{
|
||||
domain={domain ?? ''}
|
||||
isSelf={me === account.id}
|
||||
/>
|
||||
{lockedIcon}
|
||||
{account.locked && (
|
||||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
aria-label={intl.formatMessage({
|
||||
id: 'account.locked_info',
|
||||
defaultMessage:
|
||||
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</small>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{badges.length > 0 && (
|
||||
<div className='account__header__badges'>{badges}</div>
|
||||
)}
|
||||
<AccountBadges accountId={accountId} />
|
||||
|
||||
{account.id !== me && signedIn && !(suspended || hidden) && (
|
||||
{me && account.id !== me && !suspendedOrHidden && (
|
||||
<FamiliarFollowers accountId={accountId} />
|
||||
)}
|
||||
|
||||
<div className='account__header__buttons account__header__buttons--mobile'>
|
||||
{!hidden && actionBtn}
|
||||
{!hidden && bellBtn}
|
||||
{menu}
|
||||
</div>
|
||||
<AccountButtons
|
||||
className='account__header__buttons--mobile'
|
||||
accountId={accountId}
|
||||
noShare
|
||||
/>
|
||||
|
||||
{!(suspended || hidden) && (
|
||||
{!suspendedOrHidden && (
|
||||
<div className='account__header__extra'>
|
||||
<div className='account__header__bio'>
|
||||
{account.id !== me && signedIn && (
|
||||
{me && account.id !== me && (
|
||||
<AccountNote accountId={accountId} />
|
||||
)}
|
||||
|
||||
@@ -882,7 +215,10 @@ export const AccountHeader: React.FC<{
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<AccountFields fields={fields} emojis={account.emojis} />
|
||||
<AccountFields
|
||||
fields={account.fields}
|
||||
emojis={account.emojis}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -892,31 +228,13 @@ export const AccountHeader: React.FC<{
|
||||
|
||||
<ActionBar account={account} />
|
||||
|
||||
{!(hideTabs || hidden) && (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${account.acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}`}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}/with_replies`}>
|
||||
<FormattedMessage
|
||||
id='account.posts_with_replies'
|
||||
defaultMessage='Posts and replies'
|
||||
/>
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
{!hideTabs && !hidden && <AccountTabs acct={account.acct} />}
|
||||
|
||||
<Helmet>
|
||||
<title>{titleFromAccount(account)}</title>
|
||||
<meta
|
||||
name='robots'
|
||||
content={isLocal && isIndexable ? 'all' : 'noindex'}
|
||||
content={isLocal && !account.noindex ? 'all' : 'noindex'}
|
||||
/>
|
||||
<link rel='canonical' href={account.url} />
|
||||
</Helmet>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import {
|
||||
AutomatedBadge,
|
||||
Badge,
|
||||
GroupBadge,
|
||||
} from '@/flavours/glitch/components/badge';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { useAppSelector } from '@/flavours/glitch/store';
|
||||
|
||||
export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const account = useAccount(accountId);
|
||||
const localDomain = useAppSelector(
|
||||
(state) => state.meta.get('domain') as string,
|
||||
);
|
||||
const badges = [];
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (account.bot) {
|
||||
badges.push(<AutomatedBadge key='bot-badge' />);
|
||||
} else if (account.group) {
|
||||
badges.push(<GroupBadge key='group-badge' />);
|
||||
}
|
||||
|
||||
const domain = account.acct.includes('@')
|
||||
? account.acct.split('@')[1]
|
||||
: localDomain;
|
||||
account.roles.forEach((role) => {
|
||||
badges.push(
|
||||
<Badge
|
||||
key={`role-badge-${role.get('id')}`}
|
||||
label={<span>{role.get('name')}</span>}
|
||||
domain={domain}
|
||||
roleId={role.get('id')}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
if (!badges.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className='account__header__badges'>{badges}</div>;
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { followAccount } from '@/flavours/glitch/actions/accounts';
|
||||
import { CopyIconButton } from '@/flavours/glitch/components/copy_icon_button';
|
||||
import { FollowButton } from '@/flavours/glitch/components/follow_button';
|
||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { getAccountHidden } from '@/flavours/glitch/selectors/accounts';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
|
||||
import { AccountMenu } from './menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
enableNotifications: {
|
||||
id: 'account.enable_notifications',
|
||||
defaultMessage: 'Notify me when @{name} posts',
|
||||
},
|
||||
disableNotifications: {
|
||||
id: 'account.disable_notifications',
|
||||
defaultMessage: 'Stop notifying me when @{name} posts',
|
||||
},
|
||||
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
|
||||
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
|
||||
});
|
||||
|
||||
interface AccountButtonsProps {
|
||||
accountId: string;
|
||||
className?: string;
|
||||
noShare?: boolean;
|
||||
}
|
||||
|
||||
export const AccountButtons: FC<AccountButtonsProps> = ({
|
||||
accountId,
|
||||
className,
|
||||
noShare,
|
||||
}) => {
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
const me = useAppSelector((state) => state.meta.get('me') as string);
|
||||
|
||||
return (
|
||||
<div className={classNames('account__header__buttons', className)}>
|
||||
{!hidden && (
|
||||
<AccountButtonsOther accountId={accountId} noShare={noShare} />
|
||||
)}
|
||||
{accountId !== me && <AccountMenu accountId={accountId} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountButtonsOther: FC<
|
||||
Pick<AccountButtonsProps, 'accountId' | 'noShare'>
|
||||
> = ({ accountId, noShare }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleNotifyToggle = useCallback(() => {
|
||||
if (account) {
|
||||
dispatch(followAccount(account.id, { notify: !relationship?.notifying }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
const accountUrl = account?.url;
|
||||
const handleShare = useCallback(() => {
|
||||
if (accountUrl) {
|
||||
void navigator.share({
|
||||
url: accountUrl,
|
||||
});
|
||||
}
|
||||
}, [accountUrl]);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
|
||||
const isFollowing = relationship?.requested || relationship?.following;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isMovedAndUnfollowedAccount && (
|
||||
<FollowButton
|
||||
accountId={accountId}
|
||||
className='account__header__follow-button'
|
||||
labelLength='long'
|
||||
/>
|
||||
)}
|
||||
{isFollowing && (
|
||||
<IconButton
|
||||
icon={relationship.notifying ? 'bell' : 'bell-o'}
|
||||
iconComponent={
|
||||
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
|
||||
}
|
||||
active={relationship.notifying}
|
||||
title={intl.formatMessage(
|
||||
relationship.notifying
|
||||
? messages.disableNotifications
|
||||
: messages.enableNotifications,
|
||||
{ name: account.username },
|
||||
)}
|
||||
onClick={handleNotifyToggle}
|
||||
/>
|
||||
)}
|
||||
{!noShare &&
|
||||
('share' in navigator ? (
|
||||
<IconButton
|
||||
className='optional'
|
||||
icon=''
|
||||
iconComponent={ShareIcon}
|
||||
title={intl.formatMessage(messages.share, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleShare}
|
||||
/>
|
||||
) : (
|
||||
<CopyIconButton
|
||||
className='optional'
|
||||
title={intl.formatMessage(messages.copy)}
|
||||
value={account.url}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { Relationship } from '@/flavours/glitch/models/relationship';
|
||||
|
||||
export const AccountInfo: FC<{ relationship?: Relationship }> = ({
|
||||
relationship,
|
||||
}) => {
|
||||
if (!relationship) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className='account__header__info'>
|
||||
{(relationship.followed_by || relationship.requested_by) && (
|
||||
<span className='relationship-tag'>
|
||||
<AccountInfoFollower relationship={relationship} />
|
||||
</span>
|
||||
)}
|
||||
{relationship.blocking && (
|
||||
<span className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
|
||||
</span>
|
||||
)}
|
||||
{relationship.muting && (
|
||||
<span key='muting' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muting' defaultMessage='Muting' />
|
||||
</span>
|
||||
)}
|
||||
{relationship.domain_blocking && (
|
||||
<span key='domain_blocking' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocking'
|
||||
defaultMessage='Blocking domain'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountInfoFollower: FC<{ relationship: Relationship }> = ({
|
||||
relationship,
|
||||
}) => {
|
||||
if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
return (
|
||||
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.requests_to_follow_you'
|
||||
defaultMessage='Requests to follow you'
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
FollowersCounter,
|
||||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from '@/flavours/glitch/components/counters';
|
||||
import { ShortNumber } from '@/flavours/glitch/components/short_number';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
|
||||
export const AccountLinks: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__extra__links'>
|
||||
<NavLink
|
||||
to={`/@${account.acct}`}
|
||||
title={intl.formatNumber(account.statuses_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.statuses_count}
|
||||
renderer={StatusesCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/following`}
|
||||
title={intl.formatNumber(account.following_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.following_count}
|
||||
renderer={FollowingCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/followers`}
|
||||
title={intl.formatNumber(account.followers_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import {
|
||||
blockAccount,
|
||||
followAccount,
|
||||
pinAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
unpinAccount,
|
||||
} from '@/flavours/glitch/actions/accounts';
|
||||
import { removeAccountFromFollowers } from '@/flavours/glitch/actions/accounts_typed';
|
||||
import {
|
||||
directCompose,
|
||||
mentionCompose,
|
||||
} from '@/flavours/glitch/actions/compose';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
unblockDomain,
|
||||
} from '@/flavours/glitch/actions/domain_blocks';
|
||||
import { openModal } from '@/flavours/glitch/actions/modal';
|
||||
import { initMuteModal } from '@/flavours/glitch/actions/mutes';
|
||||
import { initReport } from '@/flavours/glitch/actions/reports';
|
||||
import { Dropdown } from '@/flavours/glitch/components/dropdown_menu';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { useIdentity } from '@/flavours/glitch/identity_context';
|
||||
import type { MenuItem } from '@/flavours/glitch/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
PERMISSION_MANAGE_USERS,
|
||||
} from '@/flavours/glitch/permissions';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
|
||||
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
blockDomain: {
|
||||
id: 'account.block_domain',
|
||||
defaultMessage: 'Block domain {domain}',
|
||||
},
|
||||
unblockDomain: {
|
||||
id: 'account.unblock_domain',
|
||||
defaultMessage: 'Unblock domain {domain}',
|
||||
},
|
||||
hideReblogs: {
|
||||
id: 'account.hide_reblogs',
|
||||
defaultMessage: 'Hide boosts from @{name}',
|
||||
},
|
||||
showReblogs: {
|
||||
id: 'account.show_reblogs',
|
||||
defaultMessage: 'Show boosts from @{name}',
|
||||
},
|
||||
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
||||
unendorse: {
|
||||
id: 'account.unendorse',
|
||||
defaultMessage: "Don't feature on profile",
|
||||
},
|
||||
add_or_remove_from_list: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
admin_account: {
|
||||
id: 'status.admin_account',
|
||||
defaultMessage: 'Open moderation interface for @{name}',
|
||||
},
|
||||
admin_domain: {
|
||||
id: 'status.admin_domain',
|
||||
defaultMessage: 'Open moderation interface for {domain}',
|
||||
},
|
||||
languages: {
|
||||
id: 'account.languages',
|
||||
defaultMessage: 'Change subscribed languages',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
removeFromFollowers: {
|
||||
id: 'account.remove_from_followers',
|
||||
defaultMessage: 'Remove {name} from followers',
|
||||
},
|
||||
confirmRemoveFromFollowersTitle: {
|
||||
id: 'confirmations.remove_from_followers.title',
|
||||
defaultMessage: 'Remove follower?',
|
||||
},
|
||||
confirmRemoveFromFollowersMessage: {
|
||||
id: 'confirmations.remove_from_followers.message',
|
||||
defaultMessage:
|
||||
'{name} will stop following you. Are you sure you want to proceed?',
|
||||
},
|
||||
confirmRemoveFromFollowersButton: {
|
||||
id: 'confirmations.remove_from_followers.confirm',
|
||||
defaultMessage: 'Remove follower',
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn, permissions } = useIdentity();
|
||||
|
||||
const account = useAccount(accountId);
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const menuItems = useMemo(() => {
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
const isRemote = account.acct !== account.username;
|
||||
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(mentionCompose(account));
|
||||
},
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.direct, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(directCompose(account));
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: account.url,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (!signedIn) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.hideReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: false }));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.showReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: true }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.languages),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SUBSCRIBED_LANGUAGES',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersTitle,
|
||||
),
|
||||
message: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersMessage,
|
||||
{ name: <strong>{account.acct}</strong> },
|
||||
),
|
||||
confirm: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersButton,
|
||||
),
|
||||
onConfirm: () => {
|
||||
void dispatch(
|
||||
removeAccountFromFollowers({ accountId: account.id }),
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initMuteModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(blockAccount(account.id));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initReport(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
|
||||
if (remoteDomain) {
|
||||
arr.push(null);
|
||||
|
||||
if (relationship?.domain_blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockDomain(remoteDomain));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.blockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initDomainBlockModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
) {
|
||||
arr.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [account, signedIn, permissions, intl, relationship, dispatch]);
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={menuItems.length === 0}
|
||||
items={menuItems}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||
return (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}`}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/with_replies`}>
|
||||
<FormattedMessage
|
||||
id='account.posts_with_replies'
|
||||
defaultMessage='Posts and replies'
|
||||
/>
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { ApiAnnualReportState } from '@/flavours/glitch/api/annual_report';
|
||||
import { Button } from '@/flavours/glitch/components/button';
|
||||
|
||||
@@ -19,7 +17,7 @@ export const AnnualReportAnnouncement: React.FC<
|
||||
AnnualReportAnnouncementProps
|
||||
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
|
||||
return (
|
||||
<div className={classNames('theme-dark', styles.wrapper)}>
|
||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.title'
|
||||
defaultMessage='Wrapstodon {year} has arrived'
|
||||
|
||||
@@ -81,7 +81,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||
const topHashtag = report.data.top_hashtags[0];
|
||||
|
||||
return (
|
||||
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
|
||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||
<div className={styles.header}>
|
||||
<h1>Wrapstodon {report.year}</h1>
|
||||
{account && <p>@{account.acct}</p>}
|
||||
|
||||
@@ -60,11 +60,8 @@ const AnnualReportModal: React.FC<{
|
||||
// default modal backdrop, preventing clicks to pass through.
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={classNames(
|
||||
'modal-root__modal',
|
||||
styles.modalWrapper,
|
||||
'theme-dark',
|
||||
)}
|
||||
className={classNames('modal-root__modal', styles.modalWrapper)}
|
||||
data-color-scheme='dark'
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
{!showAnnouncement ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Trie from 'substring-trie';
|
||||
|
||||
import { getUserTheme, isDarkMode } from '@/flavours/glitch/utils/theme';
|
||||
import { getIsSystemTheme, isDarkMode } from '@/flavours/glitch/utils/theme';
|
||||
import { assetHost } from 'flavours/glitch/utils/config';
|
||||
|
||||
import { autoPlayGif } from '../../initial_state';
|
||||
@@ -98,7 +98,7 @@ const emojifyTextNode = (node, customEmojis) => {
|
||||
const { filename, shortCode } = unicodeMapping[unicode_emoji];
|
||||
const title = shortCode ? `:${shortCode}:` : '';
|
||||
|
||||
const isSystemTheme = getUserTheme() === 'system';
|
||||
const isSystemTheme = getIsSystemTheme();
|
||||
|
||||
const theme = (isSystemTheme || !isDarkMode()) ? 'light' : 'dark';
|
||||
|
||||
|
||||
26
app/javascript/flavours/glitch/hooks/useAccount.ts
Normal file
26
app/javascript/flavours/glitch/hooks/useAccount.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { fetchAccount } from '../actions/accounts';
|
||||
import { createAppSelector, useAppDispatch, useAppSelector } from '../store';
|
||||
|
||||
export const accountSelector = createAppSelector(
|
||||
[
|
||||
(state) => state.accounts,
|
||||
(_, accountId: string | null | undefined) => accountId,
|
||||
],
|
||||
(accounts, accountId) => (accountId ? accounts.get(accountId) : undefined),
|
||||
);
|
||||
|
||||
export function useAccount(accountId: string | null | undefined) {
|
||||
const account = useAppSelector((state) => accountSelector(state, accountId));
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const accountInStore = !!account;
|
||||
useEffect(() => {
|
||||
if (accountId && !accountInStore) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
}
|
||||
}, [accountId, accountInStore, dispatch]);
|
||||
|
||||
return account;
|
||||
}
|
||||
@@ -5,49 +5,29 @@
|
||||
|
||||
html {
|
||||
@include base.palette;
|
||||
|
||||
&:where([data-user-theme='system']) {
|
||||
color-scheme: dark light;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@include dark.tokens;
|
||||
@include utils.invert-on-dark;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
@include dark.contrast-overrides;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@include light.tokens;
|
||||
@include utils.invert-on-light;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
@include light.contrast-overrides;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-dark,
|
||||
html:where(
|
||||
:not([data-user-theme='mastodon-light'], [data-user-theme='system'])
|
||||
) {
|
||||
[data-color-scheme='dark'],
|
||||
html:not([data-color-scheme]) {
|
||||
color-scheme: dark;
|
||||
|
||||
@include dark.tokens;
|
||||
@include utils.invert-on-dark;
|
||||
|
||||
&[data-contrast='high'],
|
||||
[data-contrast='high'] & {
|
||||
@include dark.contrast-overrides;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-user-theme='contrast'],
|
||||
html[data-user-theme='contrast'] .theme-dark {
|
||||
@include dark.contrast-overrides;
|
||||
}
|
||||
|
||||
.theme-light,
|
||||
html:where([data-user-theme='mastodon-light']) {
|
||||
[data-color-scheme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
@include light.tokens;
|
||||
@include utils.invert-on-light;
|
||||
|
||||
&[data-contrast='high'],
|
||||
[data-contrast='high'] & {
|
||||
@include light.contrast-overrides;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
export function getUserTheme() {
|
||||
const { userTheme } = document.documentElement.dataset;
|
||||
return userTheme;
|
||||
export function getIsSystemTheme() {
|
||||
const { systemTheme } = document.documentElement.dataset;
|
||||
return systemTheme === 'true';
|
||||
}
|
||||
|
||||
export function isDarkMode() {
|
||||
const { userTheme } = document.documentElement.dataset;
|
||||
return userTheme === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: userTheme !== 'mastodon-light';
|
||||
const { colorScheme } = document.documentElement.dataset;
|
||||
return colorScheme === 'dark';
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
(function (element) {
|
||||
const {userTheme} = element.dataset;
|
||||
const {colorScheme, contrast} = element.dataset;
|
||||
|
||||
const colorSchemeMediaWatcher = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const contrastMediaWatcher = window.matchMedia('(prefers-contrast: more)');
|
||||
|
||||
const updateColorScheme = () => {
|
||||
const useDarkMode = userTheme === 'system' ? colorSchemeMediaWatcher.matches : userTheme !== 'mastodon-light';
|
||||
element.dataset.mode = useDarkMode ? 'dark' : 'light';
|
||||
const useDarkMode = colorScheme === 'auto' ? colorSchemeMediaWatcher.matches : colorScheme === 'dark';
|
||||
|
||||
element.dataset.colorScheme = useDarkMode ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const updateContrast = () => {
|
||||
const useHighContrast = userTheme === 'contrast' || contrastMediaWatcher.matches;
|
||||
const useHighContrast = contrast === 'high' || contrastMediaWatcher.matches;
|
||||
|
||||
element.dataset.contrast = useHighContrast ? 'high' : 'default';
|
||||
}
|
||||
|
||||
33
app/javascript/mastodon/components/mini_card/index.tsx
Normal file
33
app/javascript/mastodon/components/mini_card/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export interface MiniCardProps {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
className?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const MiniCard: FC<MiniCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
hidden,
|
||||
}) => {
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.card, className)}
|
||||
inert={hidden ? '' : undefined}
|
||||
>
|
||||
<dt className={classes.label}>{label}</dt>
|
||||
<dd className={classes.value}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
208
app/javascript/mastodon/components/mini_card/list.tsx
Normal file
208
app/javascript/mastodon/components/mini_card/list.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FC, Key, MouseEventHandler } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { MiniCard } from '.';
|
||||
import type { MiniCardProps } from '.';
|
||||
import classes from './styles.module.css';
|
||||
|
||||
interface MiniCardListProps {
|
||||
cards?: (Pick<MiniCardProps, 'label' | 'value'> & { key?: Key })[];
|
||||
onOverflowClick?: MouseEventHandler;
|
||||
}
|
||||
|
||||
export const MiniCardList: FC<MiniCardListProps> = ({
|
||||
cards = [],
|
||||
onOverflowClick,
|
||||
}) => {
|
||||
const {
|
||||
wrapperRef,
|
||||
listRef,
|
||||
hiddenCount,
|
||||
hasOverflow,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
} = useOverflow();
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper} ref={wrapperRef}>
|
||||
<dl className={classes.list} ref={listRef} style={{ maxWidth }}>
|
||||
{cards.map((card, index) => (
|
||||
<MiniCard
|
||||
key={card.key ?? index}
|
||||
label={card.label}
|
||||
value={card.value}
|
||||
hidden={index >= hiddenIndex}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(classes.more, !hasOverflow && classes.hidden)}
|
||||
onClick={onOverflowClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='minicard.more_items'
|
||||
defaultMessage='+ {count} more'
|
||||
values={{ count: hiddenCount }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useOverflow() {
|
||||
const [hiddenIndex, setHiddenIndex] = useState(-1);
|
||||
const [hiddenCount, setHiddenCount] = useState(0);
|
||||
const [maxWidth, setMaxWidth] = useState<number | 'none'>('none');
|
||||
|
||||
// This is the item container element.
|
||||
const listRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// The main recalculation function.
|
||||
const handleRecalculate = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
if (!listEle) return;
|
||||
|
||||
const reset = () => {
|
||||
setHiddenIndex(-1);
|
||||
setHiddenCount(0);
|
||||
setMaxWidth('none');
|
||||
};
|
||||
|
||||
// Calculate the width via the parent element, minus the more button, minus the padding.
|
||||
const maxWidth =
|
||||
(listEle.parentElement?.offsetWidth ?? 0) -
|
||||
(listEle.nextElementSibling?.scrollWidth ?? 0) -
|
||||
4;
|
||||
if (maxWidth <= 0) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate through children until we exceed max width.
|
||||
let visible = 0;
|
||||
let index = 0;
|
||||
let totalWidth = 0;
|
||||
for (const child of listEle.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
const rightOffset = child.offsetLeft + child.offsetWidth;
|
||||
if (rightOffset <= maxWidth) {
|
||||
visible += 1;
|
||||
totalWidth = rightOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
// All are visible, so remove max-width restriction.
|
||||
if (visible === listEle.children.length) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the width to avoid wrapping, and set hidden count.
|
||||
setHiddenIndex(index);
|
||||
setHiddenCount(listEle.children.length - visible);
|
||||
setMaxWidth(totalWidth);
|
||||
}, []);
|
||||
|
||||
// Set up observers to watch for size and content changes.
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const mutationObserverRef = useRef<MutationObserver | null>(null);
|
||||
|
||||
// Helper to get or create the resize observer.
|
||||
const resizeObserver = useCallback(() => {
|
||||
const observer = (resizeObserverRef.current ??= new ResizeObserver(
|
||||
handleRecalculate,
|
||||
));
|
||||
return observer;
|
||||
}, [handleRecalculate]);
|
||||
|
||||
// Iterate through children and observe them for size changes.
|
||||
const handleChildrenChange = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
const observer = resizeObserver();
|
||||
|
||||
if (listEle) {
|
||||
for (const child of listEle.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
observer.observe(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleRecalculate();
|
||||
}, [handleRecalculate, resizeObserver]);
|
||||
|
||||
// Helper to get or create the mutation observer.
|
||||
const mutationObserver = useCallback(() => {
|
||||
const observer = (mutationObserverRef.current ??= new MutationObserver(
|
||||
handleChildrenChange,
|
||||
));
|
||||
return observer;
|
||||
}, [handleChildrenChange]);
|
||||
|
||||
// Set up observers.
|
||||
const handleObserve = useCallback(() => {
|
||||
if (wrapperRef.current) {
|
||||
resizeObserver().observe(wrapperRef.current);
|
||||
}
|
||||
if (listRef.current) {
|
||||
mutationObserver().observe(listRef.current, { childList: true });
|
||||
handleChildrenChange();
|
||||
}
|
||||
}, [handleChildrenChange, mutationObserver, resizeObserver]);
|
||||
|
||||
// Watch the wrapper for size changes, and recalculate when it resizes.
|
||||
const wrapperRef = useRef<HTMLElement | null>(null);
|
||||
const wrapperRefCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
wrapperRef.current = node;
|
||||
handleObserve();
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
);
|
||||
|
||||
// If there are changes to the children, recalculate which are visible.
|
||||
const listRefCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
listRef.current = node;
|
||||
handleObserve();
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleObserve();
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
if (mutationObserverRef.current) {
|
||||
mutationObserverRef.current.disconnect();
|
||||
mutationObserverRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [handleObserve]);
|
||||
|
||||
return {
|
||||
hiddenCount,
|
||||
hasOverflow: hiddenCount > 0,
|
||||
wrapperRef: wrapperRefCallback,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
listRef: listRefCallback,
|
||||
recalculate: handleRecalculate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { MiniCardList } from './list';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/MiniCard',
|
||||
component: MiniCardList,
|
||||
args: {
|
||||
cards: [
|
||||
{ label: 'Pronouns', value: 'they/them' },
|
||||
{
|
||||
label: 'Website',
|
||||
value: <a href='https://example.com'>bowie-the-db.meow</a>,
|
||||
},
|
||||
{
|
||||
label: 'Free playlists',
|
||||
value: <a href='https://soundcloud.com/bowie-the-dj'>soundcloud.com</a>,
|
||||
},
|
||||
{ label: 'Location', value: 'Purris, France' },
|
||||
],
|
||||
onOverflowClick: action('Overflow clicked'),
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
resize: 'horizontal',
|
||||
padding: '1rem',
|
||||
border: '1px solid gray',
|
||||
overflow: 'auto',
|
||||
width: '400px',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
<MiniCardList {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof MiniCardList>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const LongValue: Story = {
|
||||
args: {
|
||||
cards: [
|
||||
{
|
||||
label: 'Username',
|
||||
value: 'bowie-the-dj',
|
||||
},
|
||||
{
|
||||
label: 'Bio',
|
||||
value:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.list {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card,
|
||||
.more {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 20vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.more {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
appearance: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.label,
|
||||
.value {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,177 +1,35 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import { AccountFields } from '@/mastodon/components/account_fields';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
import {
|
||||
followAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
pinAccount,
|
||||
unpinAccount,
|
||||
removeAccountFromFollowers,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { initBlockModal } from 'mastodon/actions/blocks';
|
||||
import { mentionCompose, directCompose } from 'mastodon/actions/compose';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
unblockDomain,
|
||||
} from 'mastodon/actions/domain_blocks';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||
import { initReport } from 'mastodon/actions/reports';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
|
||||
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
|
||||
import {
|
||||
FollowersCounter,
|
||||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from 'mastodon/components/counters';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { AccountNote } from 'mastodon/features/account/components/account_note';
|
||||
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
|
||||
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_USERS,
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
} from 'mastodon/permissions';
|
||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { AccountBadges } from './badges';
|
||||
import { AccountButtons } from './buttons';
|
||||
import { FamiliarFollowers } from './familiar_followers';
|
||||
import { AccountInfo } from './info';
|
||||
import { AccountLinks } from './links';
|
||||
import { MemorialNote } from './memorial_note';
|
||||
import { MovedNote } from './moved_note';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
linkVerifiedOn: {
|
||||
id: 'account.link_verified_on',
|
||||
defaultMessage: 'Ownership of this link was checked on {date}',
|
||||
},
|
||||
account_locked: {
|
||||
id: 'account.locked_info',
|
||||
defaultMessage:
|
||||
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
|
||||
},
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
|
||||
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
|
||||
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
|
||||
media: { id: 'account.media', defaultMessage: 'Media' },
|
||||
blockDomain: {
|
||||
id: 'account.block_domain',
|
||||
defaultMessage: 'Block domain {domain}',
|
||||
},
|
||||
unblockDomain: {
|
||||
id: 'account.unblock_domain',
|
||||
defaultMessage: 'Unblock domain {domain}',
|
||||
},
|
||||
hideReblogs: {
|
||||
id: 'account.hide_reblogs',
|
||||
defaultMessage: 'Hide boosts from @{name}',
|
||||
},
|
||||
showReblogs: {
|
||||
id: 'account.show_reblogs',
|
||||
defaultMessage: 'Show boosts from @{name}',
|
||||
},
|
||||
enableNotifications: {
|
||||
id: 'account.enable_notifications',
|
||||
defaultMessage: 'Notify me when @{name} posts',
|
||||
},
|
||||
disableNotifications: {
|
||||
id: 'account.disable_notifications',
|
||||
defaultMessage: 'Stop notifying me when @{name} posts',
|
||||
},
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
follow_requests: {
|
||||
id: 'navigation_bar.follow_requests',
|
||||
defaultMessage: 'Follow requests',
|
||||
},
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: {
|
||||
id: 'navigation_bar.followed_tags',
|
||||
defaultMessage: 'Followed hashtags',
|
||||
},
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: {
|
||||
id: 'navigation_bar.domain_blocks',
|
||||
defaultMessage: 'Blocked domains',
|
||||
},
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
||||
unendorse: {
|
||||
id: 'account.unendorse',
|
||||
defaultMessage: "Don't feature on profile",
|
||||
},
|
||||
add_or_remove_from_list: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
admin_account: {
|
||||
id: 'status.admin_account',
|
||||
defaultMessage: 'Open moderation interface for @{name}',
|
||||
},
|
||||
admin_domain: {
|
||||
id: 'status.admin_domain',
|
||||
defaultMessage: 'Open moderation interface for {domain}',
|
||||
},
|
||||
languages: {
|
||||
id: 'account.languages',
|
||||
defaultMessage: 'Change subscribed languages',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
removeFromFollowers: {
|
||||
id: 'account.remove_from_followers',
|
||||
defaultMessage: 'Remove {name} from followers',
|
||||
},
|
||||
confirmRemoveFromFollowersTitle: {
|
||||
id: 'confirmations.remove_from_followers.title',
|
||||
defaultMessage: 'Remove follower?',
|
||||
},
|
||||
confirmRemoveFromFollowersMessage: {
|
||||
id: 'confirmations.remove_from_followers.message',
|
||||
defaultMessage:
|
||||
'{name} will stop following you. Are you sure you want to proceed?',
|
||||
},
|
||||
confirmRemoveFromFollowersButton: {
|
||||
id: 'confirmations.remove_from_followers.confirm',
|
||||
defaultMessage: 'Remove follower',
|
||||
},
|
||||
});
|
||||
import { AccountTabs } from './tabs';
|
||||
|
||||
const titleFromAccount = (account: Account) => {
|
||||
const displayName = account.display_name;
|
||||
@@ -191,149 +49,12 @@ export const AccountHeader: React.FC<{
|
||||
}> = ({ accountId, hideTabs }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const { signedIn, permissions } = useIdentity();
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
dispatch(unblockAccount(account.id));
|
||||
} else {
|
||||
dispatch(initBlockModal(account));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleMention = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(mentionCompose(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleDirect = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(directCompose(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(initReport(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleReblogToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.showing_reblogs) {
|
||||
dispatch(followAccount(account.id, { reblogs: false }));
|
||||
} else {
|
||||
dispatch(followAccount(account.id, { reblogs: true }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleNotifyToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.notifying) {
|
||||
dispatch(followAccount(account.id, { notify: false }));
|
||||
} else {
|
||||
dispatch(followAccount(account.id, { notify: true }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleBlockDomain = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(initDomainBlockModal(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleUnblockDomain = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = account.acct.split('@')[1];
|
||||
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(unblockDomain(domain));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleEndorseToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleAddToList = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleChangeLanguages = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SUBSCRIBED_LANGUAGES',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleOpenAvatar = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||
@@ -359,410 +80,14 @@ export const AccountHeader: React.FC<{
|
||||
[dispatch, account],
|
||||
);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.share({
|
||||
url: account.url,
|
||||
});
|
||||
}, [account]);
|
||||
|
||||
const suspended = account?.suspended;
|
||||
const isRemote = account?.acct !== account?.username;
|
||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMention,
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.direct, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleDirect,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: account.url,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.hideReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReblogToggle,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.showReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReblogToggle,
|
||||
});
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.languages),
|
||||
action: handleChangeLanguages,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: handleEndorseToggle,
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
action: handleAddToList,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersTitle,
|
||||
),
|
||||
message: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersMessage,
|
||||
{ name: <strong>{account.acct}</strong> },
|
||||
),
|
||||
confirm: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersButton,
|
||||
),
|
||||
onConfirm: () => {
|
||||
void dispatch(removeAccountFromFollowers({ accountId }));
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMute,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMute,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleBlock,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleBlock,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReport,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (signedIn && isRemote) {
|
||||
arr.push(null);
|
||||
|
||||
if (relationship?.domain_blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: handleUnblockDomain,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.blockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: handleBlockDomain,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
) {
|
||||
arr.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [
|
||||
dispatch,
|
||||
accountId,
|
||||
account,
|
||||
relationship,
|
||||
permissions,
|
||||
isRemote,
|
||||
remoteDomain,
|
||||
intl,
|
||||
signedIn,
|
||||
handleAddToList,
|
||||
handleBlock,
|
||||
handleBlockDomain,
|
||||
handleChangeLanguages,
|
||||
handleDirect,
|
||||
handleEndorseToggle,
|
||||
handleMention,
|
||||
handleMute,
|
||||
handleReblogToggle,
|
||||
handleReport,
|
||||
handleUnblockDomain,
|
||||
]);
|
||||
|
||||
const menu = accountId !== me && (
|
||||
<Dropdown
|
||||
disabled={menuItems.length === 0}
|
||||
items={menuItems}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let actionBtn: React.ReactNode,
|
||||
bellBtn: React.ReactNode,
|
||||
lockedIcon: React.ReactNode,
|
||||
shareBtn: React.ReactNode;
|
||||
|
||||
const info: React.ReactNode[] = [];
|
||||
|
||||
if (me !== account.id && relationship) {
|
||||
if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
info.push(
|
||||
<span key='mutual' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
info.push(
|
||||
<span key='followed_by' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
info.push(
|
||||
<span key='requested_by' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.requests_to_follow_you'
|
||||
defaultMessage='Requests to follow you'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.blocking) {
|
||||
info.push(
|
||||
<span key='blocking' className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.muting) {
|
||||
info.push(
|
||||
<span key='muting' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muting' defaultMessage='Muting' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.domain_blocking) {
|
||||
info.push(
|
||||
<span key='domain_blocking' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocking'
|
||||
defaultMessage='Blocking domain'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (relationship?.requested || relationship?.following) {
|
||||
bellBtn = (
|
||||
<IconButton
|
||||
icon={relationship.notifying ? 'bell' : 'bell-o'}
|
||||
iconComponent={
|
||||
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
|
||||
}
|
||||
active={relationship.notifying}
|
||||
title={intl.formatMessage(
|
||||
relationship.notifying
|
||||
? messages.disableNotifications
|
||||
: messages.enableNotifications,
|
||||
{ name: account.username },
|
||||
)}
|
||||
onClick={handleNotifyToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ('share' in navigator) {
|
||||
shareBtn = (
|
||||
<IconButton
|
||||
className='optional'
|
||||
icon=''
|
||||
iconComponent={ShareIcon}
|
||||
title={intl.formatMessage(messages.share, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleShare}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
shareBtn = (
|
||||
<CopyIconButton
|
||||
className='optional'
|
||||
title={intl.formatMessage(messages.copy)}
|
||||
value={account.url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
|
||||
|
||||
if (!isMovedAndUnfollowedAccount) {
|
||||
actionBtn = (
|
||||
<FollowButton
|
||||
accountId={accountId}
|
||||
className='account__header__follow-button'
|
||||
labelLength='long'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (account.locked) {
|
||||
lockedIcon = (
|
||||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
aria-label={intl.formatMessage(messages.account_locked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = account.fields;
|
||||
const suspendedOrHidden = hidden || account.suspended;
|
||||
const isLocal = !account.acct.includes('@');
|
||||
const username = account.acct.split('@')[0];
|
||||
const domain = isLocal ? localDomain : account.acct.split('@')[1];
|
||||
const isIndexable = !account.noindex;
|
||||
|
||||
const badges = [];
|
||||
|
||||
if (account.bot) {
|
||||
badges.push(<AutomatedBadge key='bot-badge' />);
|
||||
} else if (account.group) {
|
||||
badges.push(<GroupBadge key='group-badge' />);
|
||||
}
|
||||
|
||||
account.roles.forEach((role) => {
|
||||
badges.push(
|
||||
<Badge
|
||||
key={`role-badge-${role.get('id')}`}
|
||||
label={<span>{role.get('name')}</span>}
|
||||
domain={domain}
|
||||
roleId={role.get('id')}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='account-timeline__header'>
|
||||
@@ -776,15 +101,16 @@ export const AccountHeader: React.FC<{
|
||||
inactive: !!account.moved,
|
||||
})}
|
||||
>
|
||||
{!(suspended || hidden || account.moved) &&
|
||||
relationship?.requested_by && (
|
||||
<FollowRequestNoteContainer account={account} />
|
||||
)}
|
||||
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
|
||||
<FollowRequestNoteContainer account={account} />
|
||||
)}
|
||||
|
||||
<div className='account__header__image'>
|
||||
<div className='account__header__info'>{info}</div>
|
||||
{me !== account.id && relationship && (
|
||||
<AccountInfo relationship={relationship} />
|
||||
)}
|
||||
|
||||
{!(suspended || hidden) && (
|
||||
{!suspendedOrHidden && (
|
||||
<img
|
||||
src={autoPlayGif ? account.header : account.header_static}
|
||||
alt=''
|
||||
@@ -803,17 +129,15 @@ export const AccountHeader: React.FC<{
|
||||
onClick={handleOpenAvatar}
|
||||
>
|
||||
<Avatar
|
||||
account={suspended || hidden ? undefined : account}
|
||||
account={suspendedOrHidden ? undefined : account}
|
||||
size={92}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className='account__header__buttons account__header__buttons--desktop'>
|
||||
{!hidden && actionBtn}
|
||||
{!hidden && bellBtn}
|
||||
{!hidden && shareBtn}
|
||||
{menu}
|
||||
</div>
|
||||
<AccountButtons
|
||||
accountId={accountId}
|
||||
className='account__header__buttons--desktop'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='account__header__tabs__name'>
|
||||
@@ -829,29 +153,37 @@ export const AccountHeader: React.FC<{
|
||||
domain={domain ?? ''}
|
||||
isSelf={me === account.id}
|
||||
/>
|
||||
{lockedIcon}
|
||||
{account.locked && (
|
||||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
aria-label={intl.formatMessage({
|
||||
id: 'account.locked_info',
|
||||
defaultMessage:
|
||||
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</small>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{badges.length > 0 && (
|
||||
<div className='account__header__badges'>{badges}</div>
|
||||
)}
|
||||
<AccountBadges accountId={accountId} />
|
||||
|
||||
{account.id !== me && signedIn && !(suspended || hidden) && (
|
||||
{me && account.id !== me && !suspendedOrHidden && (
|
||||
<FamiliarFollowers accountId={accountId} />
|
||||
)}
|
||||
|
||||
<div className='account__header__buttons account__header__buttons--mobile'>
|
||||
{!hidden && actionBtn}
|
||||
{!hidden && bellBtn}
|
||||
{menu}
|
||||
</div>
|
||||
<AccountButtons
|
||||
className='account__header__buttons--mobile'
|
||||
accountId={accountId}
|
||||
noShare
|
||||
/>
|
||||
|
||||
{!(suspended || hidden) && (
|
||||
{!suspendedOrHidden && (
|
||||
<div className='account__header__extra'>
|
||||
<div className='account__header__bio'>
|
||||
{account.id !== me && signedIn && (
|
||||
{me && account.id !== me && (
|
||||
<AccountNote accountId={accountId} />
|
||||
)}
|
||||
|
||||
@@ -878,73 +210,26 @@ export const AccountHeader: React.FC<{
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<AccountFields fields={fields} emojis={account.emojis} />
|
||||
<AccountFields
|
||||
fields={account.fields}
|
||||
emojis={account.emojis}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='account__header__extra__links'>
|
||||
<NavLink
|
||||
to={`/@${account.acct}`}
|
||||
title={intl.formatNumber(account.statuses_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.statuses_count}
|
||||
renderer={StatusesCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/following`}
|
||||
title={intl.formatNumber(account.following_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.following_count}
|
||||
renderer={FollowingCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/followers`}
|
||||
title={intl.formatNumber(account.followers_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
</div>
|
||||
<AccountLinks accountId={accountId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimateEmojiProvider>
|
||||
|
||||
{!(hideTabs || hidden) && (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${account.acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}`}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}/with_replies`}>
|
||||
<FormattedMessage
|
||||
id='account.posts_with_replies'
|
||||
defaultMessage='Posts and replies'
|
||||
/>
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
{!hideTabs && !hidden && <AccountTabs acct={account.acct} />}
|
||||
|
||||
<Helmet>
|
||||
<title>{titleFromAccount(account)}</title>
|
||||
<meta
|
||||
name='robots'
|
||||
content={isLocal && isIndexable ? 'all' : 'noindex'}
|
||||
content={isLocal && !account.noindex ? 'all' : 'noindex'}
|
||||
/>
|
||||
<link rel='canonical' href={account.url} />
|
||||
</Helmet>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { AutomatedBadge, Badge, GroupBadge } from '@/mastodon/components/badge';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const account = useAccount(accountId);
|
||||
const localDomain = useAppSelector(
|
||||
(state) => state.meta.get('domain') as string,
|
||||
);
|
||||
const badges = [];
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (account.bot) {
|
||||
badges.push(<AutomatedBadge key='bot-badge' />);
|
||||
} else if (account.group) {
|
||||
badges.push(<GroupBadge key='group-badge' />);
|
||||
}
|
||||
|
||||
const domain = account.acct.includes('@')
|
||||
? account.acct.split('@')[1]
|
||||
: localDomain;
|
||||
account.roles.forEach((role) => {
|
||||
badges.push(
|
||||
<Badge
|
||||
key={`role-badge-${role.get('id')}`}
|
||||
label={<span>{role.get('name')}</span>}
|
||||
domain={domain}
|
||||
roleId={role.get('id')}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
if (!badges.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className='account__header__badges'>{badges}</div>;
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { followAccount } from '@/mastodon/actions/accounts';
|
||||
import { CopyIconButton } from '@/mastodon/components/copy_icon_button';
|
||||
import { FollowButton } from '@/mastodon/components/follow_button';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { getAccountHidden } from '@/mastodon/selectors/accounts';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
|
||||
import { AccountMenu } from './menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
enableNotifications: {
|
||||
id: 'account.enable_notifications',
|
||||
defaultMessage: 'Notify me when @{name} posts',
|
||||
},
|
||||
disableNotifications: {
|
||||
id: 'account.disable_notifications',
|
||||
defaultMessage: 'Stop notifying me when @{name} posts',
|
||||
},
|
||||
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
|
||||
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
|
||||
});
|
||||
|
||||
interface AccountButtonsProps {
|
||||
accountId: string;
|
||||
className?: string;
|
||||
noShare?: boolean;
|
||||
}
|
||||
|
||||
export const AccountButtons: FC<AccountButtonsProps> = ({
|
||||
accountId,
|
||||
className,
|
||||
noShare,
|
||||
}) => {
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
const me = useAppSelector((state) => state.meta.get('me') as string);
|
||||
|
||||
return (
|
||||
<div className={classNames('account__header__buttons', className)}>
|
||||
{!hidden && (
|
||||
<AccountButtonsOther accountId={accountId} noShare={noShare} />
|
||||
)}
|
||||
{accountId !== me && <AccountMenu accountId={accountId} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountButtonsOther: FC<
|
||||
Pick<AccountButtonsProps, 'accountId' | 'noShare'>
|
||||
> = ({ accountId, noShare }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleNotifyToggle = useCallback(() => {
|
||||
if (account) {
|
||||
dispatch(followAccount(account.id, { notify: !relationship?.notifying }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
const accountUrl = account?.url;
|
||||
const handleShare = useCallback(() => {
|
||||
if (accountUrl) {
|
||||
void navigator.share({
|
||||
url: accountUrl,
|
||||
});
|
||||
}
|
||||
}, [accountUrl]);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
|
||||
const isFollowing = relationship?.requested || relationship?.following;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isMovedAndUnfollowedAccount && (
|
||||
<FollowButton
|
||||
accountId={accountId}
|
||||
className='account__header__follow-button'
|
||||
labelLength='long'
|
||||
/>
|
||||
)}
|
||||
{isFollowing && (
|
||||
<IconButton
|
||||
icon={relationship.notifying ? 'bell' : 'bell-o'}
|
||||
iconComponent={
|
||||
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
|
||||
}
|
||||
active={relationship.notifying}
|
||||
title={intl.formatMessage(
|
||||
relationship.notifying
|
||||
? messages.disableNotifications
|
||||
: messages.enableNotifications,
|
||||
{ name: account.username },
|
||||
)}
|
||||
onClick={handleNotifyToggle}
|
||||
/>
|
||||
)}
|
||||
{!noShare &&
|
||||
('share' in navigator ? (
|
||||
<IconButton
|
||||
className='optional'
|
||||
icon=''
|
||||
iconComponent={ShareIcon}
|
||||
title={intl.formatMessage(messages.share, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleShare}
|
||||
/>
|
||||
) : (
|
||||
<CopyIconButton
|
||||
className='optional'
|
||||
title={intl.formatMessage(messages.copy)}
|
||||
value={account.url}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { Relationship } from '@/mastodon/models/relationship';
|
||||
|
||||
export const AccountInfo: FC<{ relationship?: Relationship }> = ({
|
||||
relationship,
|
||||
}) => {
|
||||
if (!relationship) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className='account__header__info'>
|
||||
{(relationship.followed_by || relationship.requested_by) && (
|
||||
<span className='relationship-tag'>
|
||||
<AccountInfoFollower relationship={relationship} />
|
||||
</span>
|
||||
)}
|
||||
{relationship.blocking && (
|
||||
<span className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
|
||||
</span>
|
||||
)}
|
||||
{relationship.muting && (
|
||||
<span key='muting' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muting' defaultMessage='Muting' />
|
||||
</span>
|
||||
)}
|
||||
{relationship.domain_blocking && (
|
||||
<span key='domain_blocking' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocking'
|
||||
defaultMessage='Blocking domain'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountInfoFollower: FC<{ relationship: Relationship }> = ({
|
||||
relationship,
|
||||
}) => {
|
||||
if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
return (
|
||||
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.requests_to_follow_you'
|
||||
defaultMessage='Requests to follow you'
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
FollowersCounter,
|
||||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from '@/mastodon/components/counters';
|
||||
import { ShortNumber } from '@/mastodon/components/short_number';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
|
||||
export const AccountLinks: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__extra__links'>
|
||||
<NavLink
|
||||
to={`/@${account.acct}`}
|
||||
title={intl.formatNumber(account.statuses_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.statuses_count}
|
||||
renderer={StatusesCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/following`}
|
||||
title={intl.formatNumber(account.following_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.following_count}
|
||||
renderer={FollowingCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/followers`}
|
||||
title={intl.formatNumber(account.followers_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,373 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import {
|
||||
blockAccount,
|
||||
followAccount,
|
||||
pinAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
unpinAccount,
|
||||
} from '@/mastodon/actions/accounts';
|
||||
import { removeAccountFromFollowers } from '@/mastodon/actions/accounts_typed';
|
||||
import { directCompose, mentionCompose } from '@/mastodon/actions/compose';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
unblockDomain,
|
||||
} from '@/mastodon/actions/domain_blocks';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { initMuteModal } from '@/mastodon/actions/mutes';
|
||||
import { initReport } from '@/mastodon/actions/reports';
|
||||
import { Dropdown } from '@/mastodon/components/dropdown_menu';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import type { MenuItem } from '@/mastodon/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
PERMISSION_MANAGE_USERS,
|
||||
} from '@/mastodon/permissions';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
|
||||
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
blockDomain: {
|
||||
id: 'account.block_domain',
|
||||
defaultMessage: 'Block domain {domain}',
|
||||
},
|
||||
unblockDomain: {
|
||||
id: 'account.unblock_domain',
|
||||
defaultMessage: 'Unblock domain {domain}',
|
||||
},
|
||||
hideReblogs: {
|
||||
id: 'account.hide_reblogs',
|
||||
defaultMessage: 'Hide boosts from @{name}',
|
||||
},
|
||||
showReblogs: {
|
||||
id: 'account.show_reblogs',
|
||||
defaultMessage: 'Show boosts from @{name}',
|
||||
},
|
||||
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
||||
unendorse: {
|
||||
id: 'account.unendorse',
|
||||
defaultMessage: "Don't feature on profile",
|
||||
},
|
||||
add_or_remove_from_list: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
admin_account: {
|
||||
id: 'status.admin_account',
|
||||
defaultMessage: 'Open moderation interface for @{name}',
|
||||
},
|
||||
admin_domain: {
|
||||
id: 'status.admin_domain',
|
||||
defaultMessage: 'Open moderation interface for {domain}',
|
||||
},
|
||||
languages: {
|
||||
id: 'account.languages',
|
||||
defaultMessage: 'Change subscribed languages',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
removeFromFollowers: {
|
||||
id: 'account.remove_from_followers',
|
||||
defaultMessage: 'Remove {name} from followers',
|
||||
},
|
||||
confirmRemoveFromFollowersTitle: {
|
||||
id: 'confirmations.remove_from_followers.title',
|
||||
defaultMessage: 'Remove follower?',
|
||||
},
|
||||
confirmRemoveFromFollowersMessage: {
|
||||
id: 'confirmations.remove_from_followers.message',
|
||||
defaultMessage:
|
||||
'{name} will stop following you. Are you sure you want to proceed?',
|
||||
},
|
||||
confirmRemoveFromFollowersButton: {
|
||||
id: 'confirmations.remove_from_followers.confirm',
|
||||
defaultMessage: 'Remove follower',
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn, permissions } = useIdentity();
|
||||
|
||||
const account = useAccount(accountId);
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const menuItems = useMemo(() => {
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
const isRemote = account.acct !== account.username;
|
||||
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(mentionCompose(account));
|
||||
},
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.direct, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(directCompose(account));
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: account.url,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (!signedIn) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.hideReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: false }));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.showReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: true }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.languages),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SUBSCRIBED_LANGUAGES',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersTitle,
|
||||
),
|
||||
message: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersMessage,
|
||||
{ name: <strong>{account.acct}</strong> },
|
||||
),
|
||||
confirm: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersButton,
|
||||
),
|
||||
onConfirm: () => {
|
||||
void dispatch(
|
||||
removeAccountFromFollowers({ accountId: account.id }),
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initMuteModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(blockAccount(account.id));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initReport(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
|
||||
if (remoteDomain) {
|
||||
arr.push(null);
|
||||
|
||||
if (relationship?.domain_blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockDomain(remoteDomain));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.blockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initDomainBlockModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
) {
|
||||
arr.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [account, signedIn, permissions, intl, relationship, dispatch]);
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={menuItems.length === 0}
|
||||
items={menuItems}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||
return (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}`}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/with_replies`}>
|
||||
<FormattedMessage
|
||||
id='account.posts_with_replies'
|
||||
defaultMessage='Posts and replies'
|
||||
/>
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
|
||||
@@ -19,7 +17,7 @@ export const AnnualReportAnnouncement: React.FC<
|
||||
AnnualReportAnnouncementProps
|
||||
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
|
||||
return (
|
||||
<div className={classNames('theme-dark', styles.wrapper)}>
|
||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.title'
|
||||
defaultMessage='Wrapstodon {year} has arrived'
|
||||
|
||||
@@ -81,7 +81,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||
const topHashtag = report.data.top_hashtags[0];
|
||||
|
||||
return (
|
||||
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
|
||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||
<div className={styles.header}>
|
||||
<h1>Wrapstodon {report.year}</h1>
|
||||
{account && <p>@{account.acct}</p>}
|
||||
|
||||
@@ -60,11 +60,8 @@ const AnnualReportModal: React.FC<{
|
||||
// default modal backdrop, preventing clicks to pass through.
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={classNames(
|
||||
'modal-root__modal',
|
||||
styles.modalWrapper,
|
||||
'theme-dark',
|
||||
)}
|
||||
className={classNames('modal-root__modal', styles.modalWrapper)}
|
||||
data-color-scheme='dark'
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
{!showAnnouncement ? (
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('emoji', () => {
|
||||
|
||||
it('does an emoji containing ZWJ properly', () => {
|
||||
expect(emojify('💂♀️💂♂️'))
|
||||
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg">');
|
||||
.toEqual('<img draggable="false" class="emojione" alt="💂♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f.svg"><img draggable="false" class="emojione" alt="💂♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f.svg">');
|
||||
});
|
||||
|
||||
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Trie from 'substring-trie';
|
||||
|
||||
import { getUserTheme, isDarkMode } from '@/mastodon/utils/theme';
|
||||
import { getIsSystemTheme, isDarkMode } from '@/mastodon/utils/theme';
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
|
||||
import { autoPlayGif } from '../../initial_state';
|
||||
@@ -98,7 +98,7 @@ const emojifyTextNode = (node, customEmojis) => {
|
||||
const { filename, shortCode } = unicodeMapping[unicode_emoji];
|
||||
const title = shortCode ? `:${shortCode}:` : '';
|
||||
|
||||
const isSystemTheme = getUserTheme() === 'system';
|
||||
const isSystemTheme = getIsSystemTheme();
|
||||
|
||||
const theme = (isSystemTheme || !isDarkMode()) ? 'light' : 'dark';
|
||||
|
||||
|
||||
26
app/javascript/mastodon/hooks/useAccount.ts
Normal file
26
app/javascript/mastodon/hooks/useAccount.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { fetchAccount } from '../actions/accounts';
|
||||
import { createAppSelector, useAppDispatch, useAppSelector } from '../store';
|
||||
|
||||
export const accountSelector = createAppSelector(
|
||||
[
|
||||
(state) => state.accounts,
|
||||
(_, accountId: string | null | undefined) => accountId,
|
||||
],
|
||||
(accounts, accountId) => (accountId ? accounts.get(accountId) : undefined),
|
||||
);
|
||||
|
||||
export function useAccount(accountId: string | null | undefined) {
|
||||
const account = useAppSelector((state) => accountSelector(state, accountId));
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const accountInStore = !!account;
|
||||
useEffect(() => {
|
||||
if (accountId && !accountInStore) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
}
|
||||
}, [accountId, accountInStore, dispatch]);
|
||||
|
||||
return account;
|
||||
}
|
||||
@@ -589,6 +589,7 @@
|
||||
"load_pending": "{count, plural, one {# new item} other {# new items}}",
|
||||
"loading_indicator.label": "Loading…",
|
||||
"media_gallery.hide": "Hide",
|
||||
"minicard.more_items": "+ {count} more",
|
||||
"moved_to_account_banner.text": "Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Hide from notifications",
|
||||
"mute_modal.hide_options": "Hide options",
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
"confirmations.private_quote_notify.title": "A bheil thu airson a cho-roinneadh leis an luchd-leantainn ’s na cleachdaichean le iomradh orra?",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Na cuiribh seo ’nam chuimhne a-rithist",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Tha mi agaibh",
|
||||
"confirmations.quiet_post_quote_info.message": "Nuair a luaidheas tu post a tha poblach ach sàmhach, thèid am post agad fhalach o loidhnichean-ama nan treandaichean.",
|
||||
"confirmations.quiet_post_quote_info.message": "Nuair a luaidheas tu post sàmhach, thèid am post agad fhalach o loidhnichean-ama nan treandaichean.",
|
||||
"confirmations.quiet_post_quote_info.title": "Luaidh air postaichean sàmhach",
|
||||
"confirmations.redraft.confirm": "Sguab às ⁊ dèan dreachd ùr",
|
||||
"confirmations.redraft.message": "A bheil thu cinnteach gu bheil thu airson am post seo a sguabadh às agus dreachd ùr a thòiseachadh? Caillidh tu gach annsachd is brosnachadh air agus thèid freagairtean dhan phost thùsail ’nan dìlleachdanan.",
|
||||
@@ -790,7 +790,7 @@
|
||||
"privacy.quote.disabled": "{visibility}, luaidh à comas",
|
||||
"privacy.quote.limited": "{visibility}, luaidh cuingichte",
|
||||
"privacy.unlisted.additional": "Tha seo coltach ris an fhaicsinneachd phoblach ach cha nochd am post air loidhnichean-ama an t-saoghail phoblaich, nan tagaichean hais no an rùrachaidh no ann an toraidhean luirg Mhastodon fiù ’s ma thug thu ro-aonta airson sin seachad.",
|
||||
"privacy.unlisted.long": "Poblach ach falaichte o na toraidhean-luirg, na treandaichean ’s na loichnichean-ama poblach",
|
||||
"privacy.unlisted.long": "Falaichte o na toraidhean-luirg, na treandaichean ’s na loidhnichean-ama poblach",
|
||||
"privacy.unlisted.short": "Sàmhach",
|
||||
"privacy_policy.last_updated": "An t-ùrachadh mu dheireadh {date}",
|
||||
"privacy_policy.title": "Poileasaidh prìobhaideachd",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
export function getUserTheme() {
|
||||
const { userTheme } = document.documentElement.dataset;
|
||||
return userTheme;
|
||||
export function getIsSystemTheme() {
|
||||
const { systemTheme } = document.documentElement.dataset;
|
||||
return systemTheme === 'true';
|
||||
}
|
||||
|
||||
export function isDarkMode() {
|
||||
const { userTheme } = document.documentElement.dataset;
|
||||
return userTheme === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: userTheme !== 'mastodon-light';
|
||||
const { colorScheme } = document.documentElement.dataset;
|
||||
return colorScheme === 'dark';
|
||||
}
|
||||
|
||||
@@ -5,49 +5,29 @@
|
||||
|
||||
html {
|
||||
@include base.palette;
|
||||
|
||||
&:where([data-user-theme='system']) {
|
||||
color-scheme: dark light;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@include dark.tokens;
|
||||
@include utils.invert-on-dark;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
@include dark.contrast-overrides;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@include light.tokens;
|
||||
@include utils.invert-on-light;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
@include light.contrast-overrides;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-dark,
|
||||
html:where(
|
||||
:not([data-user-theme='mastodon-light'], [data-user-theme='system'])
|
||||
) {
|
||||
[data-color-scheme='dark'],
|
||||
html:not([data-color-scheme]) {
|
||||
color-scheme: dark;
|
||||
|
||||
@include dark.tokens;
|
||||
@include utils.invert-on-dark;
|
||||
|
||||
&[data-contrast='high'],
|
||||
[data-contrast='high'] & {
|
||||
@include dark.contrast-overrides;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-user-theme='contrast'],
|
||||
html[data-user-theme='contrast'] .theme-dark {
|
||||
@include dark.contrast-overrides;
|
||||
}
|
||||
|
||||
.theme-light,
|
||||
html:where([data-user-theme='mastodon-light']) {
|
||||
[data-color-scheme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
@include light.tokens;
|
||||
@include utils.invert-on-light;
|
||||
|
||||
&[data-contrast='high'],
|
||||
[data-contrast='high'] & {
|
||||
@include light.contrast-overrides;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
!!! 5
|
||||
%html{ lang: I18n.locale, class: html_classes, 'data-user-theme': current_skin.parameterize, 'data-user-flavour': current_flavour.parameterize, 'data-contrast': contrast.parameterize, 'data-mode': color_scheme.parameterize }
|
||||
%html{ html_attributes }
|
||||
%head
|
||||
%meta{ charset: 'utf-8' }/
|
||||
%meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover' }/
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
= flavoured_vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous'
|
||||
|
||||
- content_for :html_classes, 'theme-dark'
|
||||
- content_for :force_color_scheme, 'dark'
|
||||
|
||||
#wrapstodon
|
||||
= render_wrapstodon_share_data @generated_annual_report
|
||||
|
||||
@@ -2054,7 +2054,7 @@ gd:
|
||||
public: Poblach
|
||||
public_long: Neach sam bith taobh a-staigh no a-muigh Mhastodon
|
||||
unlisted: Sàmhach
|
||||
unlisted_long: Falaichte o na toraidhean-luirg, na treandaichean ’s na loichnichean-ama poblach
|
||||
unlisted_long: Falaichte o na toraidhean-luirg, na treandaichean ’s na loidhnichean-ama poblach
|
||||
statuses_cleanup:
|
||||
enabled: Sguab às seann-phostaichean gu fèin-obrachail
|
||||
enabled_hint: Sguabaidh seo às na seann-phostaichean agad gu fèin-obrachail nuair a ruigeas iad stairsneach aoise sònraichte ach ma fhreagras iad ri gin dhe na h-eisgeachdan gu h-ìosal
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"marky": "^1.2.5",
|
||||
"path-complete-extname": "^1.0.0",
|
||||
"postcss-preset-env": "^10.1.5",
|
||||
"postcss-preset-env": "^11.0.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"punycode": "^2.3.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -32,7 +32,7 @@ RSpec.describe 'Content-Security-Policy' do
|
||||
img-src 'self' data: blob: #{local_domain}
|
||||
manifest-src 'self' #{local_domain}
|
||||
media-src 'self' data: #{local_domain}
|
||||
script-src 'self' #{local_domain} 'wasm-unsafe-eval' 'sha256-Q/2Cjx8v06hAdOF8/DeBUpsmBcSj7sLN3I/WpTF8T8c='
|
||||
script-src 'self' #{local_domain} 'wasm-unsafe-eval' 'sha256-Z5KW83D+6/pygIQS3h9XDpF52xW3l3BHc7JL9tj3uMs='
|
||||
style-src 'self' #{local_domain} 'nonce-ZbA+JmE7+bK8F5qvADZHuQ=='
|
||||
worker-src 'self' blob: #{local_domain}
|
||||
CSP
|
||||
|
||||
Reference in New Issue
Block a user