Merge remote-tracking branch 'origin/develop' into redesign-interaction-bar

This commit is contained in:
Alex Gleason 2022-11-27 15:05:49 -06:00
commit 33dc2f1a02
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
187 changed files with 1738 additions and 1872 deletions

View File

@ -28,6 +28,8 @@ busybox unzip soapbox.zip -o -d /opt/pleroma/instance
The change will take effect immediately, just refresh your browser tab.
It's not necessary to restart the Pleroma service.
***For OTP releases,*** *unpack to /var/lib/pleroma instead.*
To remove Soapbox and revert to the default pleroma-fe, simply `rm /opt/pleroma/instance/static/index.html` (you can delete other stuff in there too, but be careful not to delete your own HTML files).
## :elephant: Deploy on Mastodon

Binary file not shown.

View File

@ -1,12 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Generated by IcoMoon</metadata>
<defs>
<font id="icomoon" horiz-adv-x="1024">
<font-face units-per-em="1024" ascent="960" descent="-64" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe900;" glyph-name="spinster" d="M512 857.6c-226.216 0-409.6-183.384-409.6-409.6v0c0-226.216 183.384-409.6 409.6-409.6v0c226.216 0 409.6 183.384 409.6 409.6v0c0 226.216-183.384 409.6-409.6 409.6v0zM525.84 706.224c32.633 0 64.793-3.777 96.48-11.344 31.687-7.095 59.576-17.748 83.696-31.936l-43.264-104.272c-47.294 25.539-93.176 38.304-137.632 38.304-27.903 0-48.239-4.255-61.008-12.768-12.769-8.040-19.152-18.677-19.152-31.92s7.57-23.171 22.704-29.792c15.134-6.621 39.493-13.482 73.072-20.576 37.835-8.040 69.039-16.797 93.632-26.256 25.066-8.986 46.588-23.648 64.56-43.984 18.445-19.863 27.664-47.059 27.664-81.584 0-29.795-8.279-56.744-24.832-80.864s-41.374-43.515-74.48-58.176c-33.106-14.188-73.314-21.28-120.608-21.28-40.2 0-79.205 4.964-117.040 14.896s-68.577 23.175-92.224 39.728l46.112 103.568c22.228-14.661 48.006-26.486 77.328-35.472s58.168-13.472 86.544-13.472c53.915 0 80.864 13.475 80.864 40.432 0 14.188-7.801 24.595-23.408 31.216-15.134 7.094-39.724 14.432-73.776 22-37.362 8.040-68.582 16.551-93.648 25.536-25.066 9.459-46.572 24.352-64.544 44.688s-26.96 47.763-26.96 82.288c0 30.268 8.279 57.464 24.832 81.584 16.553 24.592 41.143 43.988 73.776 58.176 33.106 14.188 73.545 21.28 121.312 21.28z" />
<glyph unicode="&#xe901;" glyph-name="fediverse" d="M553.99 908.789c-46.369-0.785-83.969-37.261-86.545-83.096l-0.010-0.231c-0.083-1.432-0.13-3.108-0.13-4.794 0-46.987 36.77-85.385 83.105-87.99l0.231-0.010c1.432-0.083 3.107-0.13 4.794-0.13 46.988 0 85.387 36.772 87.99 83.108l0.010 0.231c0.083 1.431 0.13 3.106 0.13 4.791 0 46.988-36.771 85.387-83.108 87.99l-0.231 0.010c-1.441 0.084-3.127 0.132-4.823 0.132-0.497 0-0.993-0.004-1.487-0.012l0.075 0.001zM459.882 805.031l-251.29-127.347c13.547-13.809 23-31.679 26.366-51.617l0.080-0.57 251.287 127.353c-13.545 13.808-22.997 31.675-26.363 51.611l-0.080 0.57zM641.318 775.903c-9.415-17.78-23.636-31.938-40.939-41.021l-0.532-0.254 198.787-199.554c9.415 17.78 23.634 31.938 40.936 41.021l0.532 0.254zM487.306 751.83l-147.023-287.024 43.408-43.576 155.667 303.891c-20.483 3.55-38.302 13.087-52.060 26.716l0.007-0.007zM599.388 734.397c-12.846-6.718-28.060-10.66-44.195-10.66-1.77 0-3.529 0.047-5.276 0.141l0.244-0.010c-3.232 0.199-6.15 0.516-9.026 0.959l0.542-0.069 22.259-142.535 60.737-9.746zM138.038 697.983c-46.37-0.783-83.972-37.26-86.548-83.095l-0.010-0.231c-0.083-1.432-0.13-3.107-0.13-4.794 0-46.988 36.772-85.387 83.108-87.99l0.231-0.010c1.432-0.083 3.107-0.13 4.794-0.13 46.988 0 85.387 36.772 87.99 83.108l0.010 0.231c0.083 1.432 0.13 3.107 0.13 4.794 0 46.988-36.772 85.387-83.108 87.99l-0.231 0.010c-1.43 0.083-3.103 0.13-4.787 0.13-0.51 0-1.018-0.004-1.526-0.013l0.076 0.001zM235.216 624.428c0.752-4.537 1.182-9.766 1.182-15.095 0-1.667-0.042-3.325-0.125-4.972l0.009 0.231c-0.796-13.969-4.43-26.918-10.33-38.52l0.254 0.551 142.645-22.911 28.036 54.751zM479.695 585.167l-28.039-54.757 337.040-54.13c-0.697 4.368-1.096 9.405-1.096 14.535 0 1.678 0.043 3.346 0.127 5.002l-0.009-0.232c0.815 14.158 4.546 27.272 10.597 38.992l-0.254-0.542zM883.076 578.43c-46.37-0.783-83.972-37.26-86.548-83.095l-0.010-0.231c-0.083-1.432-0.13-3.107-0.13-4.794 0-46.988 36.772-85.387 83.108-87.99l0.231-0.010c1.432-0.083 3.107-0.13 4.794-0.13 46.988 0 85.387 36.772 87.99 83.108l0.010 0.231c0.083 1.432 0.13 3.107 0.13 4.794 0 46.988-36.772 85.387-83.108 87.99l-0.231 0.010c-1.438 0.084-3.119 0.131-4.812 0.131-0.501 0-1-0.004-1.499-0.012l0.075 0.001zM225.366 565.098c-9.414-17.777-23.632-31.933-40.931-41.016l-0.532-0.254 227.623-228.511 54.877 27.811zM182.639 523.19c-12.642-6.466-27.577-10.256-43.397-10.256-1.77 0-3.529 0.047-5.276 0.141l0.244-0.010c-3.521 0.199-6.741 0.548-9.909 1.050l0.551-0.072 43.485-278.147c12.642 6.466 27.577 10.256 43.397 10.256 1.77 0 3.529-0.047 5.276-0.141l-0.244 0.010c3.519-0.2 6.737-0.548 9.903-1.050l-0.55 0.072zM576.873 499.359l52.629-336.996c12.457 6.245 27.143 9.902 42.682 9.902 1.773 0 3.535-0.048 5.285-0.142l-0.244 0.010c3.8-0.219 7.286-0.616 10.711-1.192l-0.569 0.079-49.754 318.595zM788.965 474.681l-128.865-65.308 9.501-60.776 145.806 73.896c-13.546 13.809-22.998 31.679-26.363 51.617l-0.080 0.57zM816.386 421.477l-128.362-250.594c20.486-3.55 38.307-13.087 52.065-26.719l-0.007 0.007 128.359 250.591c-20.485 3.551-38.305 13.090-52.062 26.722l0.007-0.007zM302.044 390.153l-74.471-145.382c20.481-3.55 38.298-13.086 52.054-26.714l-0.007 0.007 65.83 128.515zM585.292 371.462l-304.691-154.416c13.549-13.81 23.003-31.682 26.368-51.622l0.080-0.57 287.744 145.83zM525.607 263.696l-54.877-27.811 115.337-115.788c9.415 17.78 23.636 31.938 40.939 41.021l0.532 0.254zM210.049 237.339c-46.369-0.785-83.969-37.261-86.545-83.096l-0.010-0.231c-0.083-1.432-0.13-3.107-0.13-4.794 0-46.988 36.772-85.387 83.108-87.99l0.231-0.010c1.432-0.083 3.107-0.13 4.794-0.13 46.988 0 85.387 36.772 87.99 83.108l0.010 0.231c0.083 1.432 0.13 3.107 0.13 4.794 0 46.988-36.772 85.387-83.108 87.99l-0.231 0.010c-1.438 0.084-3.12 0.132-4.813 0.132-0.501 0-1.002-0.004-1.501-0.013l0.075 0.001zM307.279 163.476c0.72-4.438 1.131-9.554 1.131-14.766 0-1.675-0.042-3.34-0.126-4.993l0.009 0.232c-0.806-14.078-4.495-27.122-10.481-38.793l0.254 0.546 278.1-44.626c-0.721 4.442-1.133 9.563-1.133 14.779 0 1.671 0.042 3.332 0.126 4.983l-0.009-0.232c0.807 14.078 4.497 27.12 10.484 38.79l-0.254-0.546zM670.509 163.451c-46.37-0.783-83.972-37.26-86.548-83.095l-0.010-0.231c-0.083-1.432-0.13-3.107-0.13-4.794 0-46.988 36.772-85.387 83.108-87.99l0.231-0.010c1.432-0.083 3.107-0.13 4.794-0.13 46.988 0 85.387 36.772 87.99 83.108l0.010 0.231c0.083 1.432 0.13 3.107 0.13 4.794 0 46.988-36.772 85.387-83.108 87.99l-0.231 0.010c-1.438 0.084-3.119 0.131-4.812 0.131-0.501 0-1-0.004-1.499-0.012l0.075 0.001z" />
</font></defs></svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

View File

@ -1,6 +1,5 @@
# Custom icons
- fediverse.svg - Modified from Wikipedia, CC0
- verified.svg - Created by Alex Gleason. CC0
Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1792" height="1792" viewBox="0 0 1792 1792" fill="currentColor">
<path d="M 343.16176,591.34767 A 171.09965,171.09965 0 0 1 163.0094,752.88799 171.09965,171.09965 0 0 1 1.4690156,572.73567 171.09965,171.09965 0 0 1 181.62138,411.19523 171.09965,171.09965 0 0 1 343.16176,591.34767 Z M 482.96755,1485.6494 A 171.09965,171.09965 0 0 1 302.81519,1647.1895 171.09965,171.09965 0 0 1 141.2748,1467.0372 171.09965,171.09965 0 0 1 321.42717,1305.4967 171.09965,171.09965 0 0 1 482.96755,1485.6494 Z m 893.94285,143.4473 a 171.09965,171.09965 0 0 1 -180.1523,161.5405 171.09965,171.09965 0 0 1 -161.5404,-180.1524 171.09965,171.09965 0 0 1 180.1524,-161.5405 171.09965,171.09965 0 0 1 161.5403,180.1524 z M 1789.5918,823.45087 A 171.09965,171.09965 0 0 1 1609.4395,984.99118 171.09965,171.09965 0 0 1 1447.899,804.83886 171.09965,171.09965 0 0 1 1628.0513,643.29854 171.09965,171.09965 0 0 1 1789.5918,823.45087 Z M 1150.7,182.08661 A 171.09965,171.09965 0 0 1 970.54747,343.627 171.09965,171.09965 0 0 1 809.00715,163.47462 171.09965,171.09965 0 0 1 989.15948,1.9342312 171.09965,171.09965 0 0 1 1150.7,182.08661 Z m -792.52346,371.6819 a 188.20963,188.20963 0 0 1 2.07029,38.5086 188.20963,188.20963 0 0 1 -19.56107,73.71432 l 276.93395,44.47923 54.4306,-106.2947 z m 474.63645,76.2221 -54.43595,106.30538 654.33596,105.0888 a 188.20963,188.20963 0 0 1 -1.8996,-37.47876 188.20963,188.20963 0 0 1 20.0788,-74.64799 z M 1065.1875,340.27205 a 188.20963,188.20963 0 0 1 -95.56964,20.44149 188.20963,188.20963 0 0 1 -16.47175,-1.72883 l 43.21482,276.72059 117.91607,18.92069 z m -43.7109,456.30786 102.1755,654.25059 a 188.20963,188.20963 0 0 1 92.651,-18.9688 188.20963,188.20963 0 0 1 19.6891,2.1609 L 1139.398,815.49535 Z M 794.34712,203.1417 306.48853,450.37661 a 188.20963,188.20963 0 0 1 51.34118,101.31626 L 845.683,304.44741 A 188.20963,188.20963 0 0 1 794.34712,203.1417 Z m 352.24368,56.54894 a 188.20963,188.20963 0 0 1 -80.5121,80.13321 l 385.9286,387.41724 a 188.20963,188.20963 0 0 1 80.5069,-80.13321 z m 339.8804,688.09027 -249.2037,486.50849 a 188.20963,188.20963 0 0 1 101.0656,51.8588 L 1587.5315,999.64494 A 188.20963,188.20963 0 0 1 1486.4712,947.78091 Z M 498.08153,1448.6696 a 188.20963,188.20963 0 0 1 1.9689,37.9111 188.20963,188.20963 0 0 1 -19.85455,74.2528 l 539.90952,86.6376 a 188.20963,188.20963 0 0 1 -1.9742,-37.9162 188.20963,188.20963 0 0 1 19.8598,-74.2477 z M 256.10246,750.31321 a 188.20963,188.20963 0 0 1 -94.02233,19.65712 188.20963,188.20963 0 0 1 -18.16843,-1.89976 l 84.42322,540.00023 a 188.20963,188.20963 0 0 1 94.02233,-19.6571 188.20963,188.20963 0 0 1 18.15773,1.9001 z M 847.58784,306.427 562.15394,863.6618 646.42776,948.26106 948.64274,358.28043 A 188.20963,188.20963 0 0 1 847.58784,306.427 Z m -359.67106,702.1662 -144.57913,282.2484 a 188.20963,188.20963 0 0 1 101.04426,51.8481 l 127.80337,-249.5025 z m 945.31912,-164.10293 -250.1803,126.78949 18.446,117.99084 283.07,-143.4639 A 188.20963,188.20963 0 0 1 1433.2359,844.49027 Z M 1037.8202,1044.882 446.28679,1344.6691 a 188.20963,188.20963 0 0 1 51.34651,101.3273 l 558.6328,-283.1181 z M 339.05298,668.95274 a 188.20963,188.20963 0 0 1 -80.49604,80.12252 l 441.91192,443.63544 106.54017,-53.9931 z m 582.89469,585.14656 -106.54012,53.993 223.91735,224.7922 a 188.20963,188.20963 0 0 1 80.5121,-80.1332 z" fill-opacity=".996"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -87,7 +87,7 @@
"compose_form.poll.add_option": "Add a choice",
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.option_placeholder": "Choice {number}",
"compose_form.poll.remove_option": "Remove this choice",
"compose_form.poll.remove_option": "Delete",
"compose_form.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.",
"compose_form.publish": "Publish",
"compose_form.publish_loud": "{publish}!",
@ -566,7 +566,7 @@
"compose_form.poll.add_option": "Add a choice",
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.option_placeholder": "Choice {number}",
"compose_form.poll.remove_option": "Remove this choice",
"compose_form.poll.remove_option": "Delete",
"compose_form.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.",
"compose_form.publish": "Publish",
"compose_form.publish_loud": "{publish}!",

View File

@ -16,6 +16,7 @@ import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
import { startOnboarding } from 'soapbox/actions/onboarding';
import snackbar from 'soapbox/actions/snackbar';
import { custom } from 'soapbox/custom';
import { queryClient } from 'soapbox/queries/client';
import KVStore from 'soapbox/storage/kv-store';
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
import sourceCode from 'soapbox/utils/code';
@ -239,8 +240,14 @@ export const logOut = () =>
token: state.auth.getIn(['users', account.url, 'access_token']),
};
return dispatch(revokeOAuthToken(params)).finally(() => {
return dispatch(revokeOAuthToken(params))
.finally(() => {
// Clear all stored cache from React Query
queryClient.invalidateQueries();
queryClient.clear();
dispatch({ type: AUTH_LOGGED_OUT, account, standalone });
return dispatch(snackbar.success(messages.loggedOut));
});
};
@ -248,6 +255,10 @@ export const logOut = () =>
export const switchAccount = (accountId: string, background = false) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const account = getState().accounts.get(accountId);
// Clear all stored cache from React Query
queryClient.invalidateQueries();
queryClient.clear();
return dispatch({ type: SWITCH_ACCOUNT, account, background });
};

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
interface IInlineSVG {
loader?: JSX.Element,

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { Link, useHistory } from 'react-router-dom';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
@ -199,7 +199,7 @@ const Account = ({
title={account.acct}
onClick={(event: React.MouseEvent) => event.stopPropagation()}
>
<div className='flex items-center space-x-1 flex-grow' style={style}>
<HStack space={1} alignItems='center' grow style={style}>
<Text
size='sm'
weight='semibold'
@ -208,7 +208,7 @@ const Account = ({
/>
{account.verified && <VerificationBadge />}
</div>
</HStack>
</LinkEl>
</ProfilePopper>
@ -255,7 +255,7 @@ const Account = ({
<Text
size='sm'
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
className='mr-2'
className='mr-2 rtl:ml-2 rtl:mr-0'
/>
)}
</Stack>

View File

@ -28,7 +28,7 @@ const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
ref={input}
type='text'
value={value}
className='rounded-r-none'
className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg'
outerClassName='flex-grow'
onClick={selectInput}
readOnly
@ -36,7 +36,7 @@ const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
<Button
theme='primary'
className='mt-1 h-full rounded-l-none rounded-r-lg'
className='mt-1 h-full rounded-l-none rounded-r-lg rtl:rounded-l-lg rtl:rounded-r-none'
onClick={selectInput}
>
<FormattedMessage id='input.copy' defaultMessage='Copy' />

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import { useSoapboxConfig } from 'soapbox/hooks';
@ -7,16 +7,18 @@ import { getAcct } from '../utils/accounts';
import Icon from './icon';
import RelativeTimestamp from './relative-timestamp';
import { HStack, Text } from './ui';
import VerificationBadge from './verification-badge';
import type { Account } from 'soapbox/types/entities';
interface IDisplayName {
account: Account
withSuffix?: boolean
withDate?: boolean
}
const DisplayName: React.FC<IDisplayName> = ({ account, children, withDate = false }) => {
const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = true, withDate = false }) => {
const { displayFqn = false } = useSoapboxConfig();
const { created_at: createdAt, verified } = account;
@ -28,11 +30,17 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withDate = fal
) : null;
const displayName = (
<span className='display-name__name'>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>
<HStack space={1} alignItems='center' grow>
<Text
size='sm'
weight='semibold'
truncate
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
{verified && <VerificationBadge />}
{withDate && joinedAt}
</span>
</HStack>
);
const suffix = (<span className='display-name__account'>@{getAcct(account, displayFqn)}</span>);
@ -42,7 +50,7 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withDate = fal
<HoverRefWrapper accountId={account.get('id')} inline>
{displayName}
</HoverRefWrapper>
{suffix}
{withSuffix && suffix}
{children}
</span>
);

View File

@ -6,7 +6,7 @@ import { spring } from 'react-motion';
import Overlay from 'react-overlays/lib/Overlay';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { IconButton, Counter } from 'soapbox/components/ui';
import { Counter, IconButton } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import Motion from 'soapbox/features/ui/util/optional-motion';
@ -196,7 +196,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
data-method={isLogout ? 'delete' : undefined}
title={text}
>
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />}
{icon && <SvgIcon src={icon} className='mr-3 rtl:ml-3 rtl:mr-0 h-5 w-5 flex-none' />}
<span className='truncate'>{text}</span>
@ -259,6 +259,7 @@ export interface IDropdown extends RouteComponentProps {
text?: string,
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
children?: JSX.Element,
dropdownMenuStyle?: React.CSSProperties,
}
interface IDropdownState {
@ -369,7 +370,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
}
render() {
const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children } = this.props;
const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props;
const open = this.state.id === openDropdownId;
return (
@ -403,7 +404,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
)}
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} style={dropdownMenuStyle} />
</Overlay>
</>
);

View File

@ -5,7 +5,7 @@ import { useDispatch } from 'react-redux';
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
import { openModal } from 'soapbox/actions/modals';
import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector';
import { EmojiSelector } from 'soapbox/components/ui';
import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';

View File

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import * as BuildConfig from 'soapbox/build-config';
import { Text, Stack } from 'soapbox/components/ui';
import { HStack, Text, Stack } from 'soapbox/components/ui';
import { captureException } from 'soapbox/monitoring';
import KVStore from 'soapbox/storage/kv-store';
import sourceCode from 'soapbox/utils/code';
@ -179,7 +179,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
</main>
<footer className='flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'>
<nav className='flex justify-center space-x-4'>
<HStack justifyContent='center' space={4} element='nav'>
{links.get('status') && (
<>
<a href={links.get('status')} className='text-sm font-medium text-gray-700 dark:text-gray-600 hover:underline'>
@ -205,7 +205,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
</a>
</>
)}
</nav>
</HStack>
</footer>
</div>
);

View File

@ -31,7 +31,7 @@ const GdprBanner: React.FC = () => {
return (
<Banner theme='opaque' className={classNames('transition-transform', { 'translate-y-full': slideout })}>
<div className='flex flex-col space-y-4 lg:space-y-0 lg:space-x-4 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex flex-col space-y-4 lg:space-y-0 lg:space-x-4 rtl:space-x-reverse lg:flex-row lg:items-center lg:justify-between'>
<Stack space={2}>
<Text size='xl' weight='bold'>
<FormattedMessage id='gdpr.title' defaultMessage='{siteTitle} uses cookies' values={{ siteTitle }} />

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { Helmet as ReactHelmet } from 'react-helmet';
import { useAppSelector, useSettings } from 'soapbox/hooks';

View File

@ -1,11 +1,11 @@
import classNames from 'clsx';
import * as React from 'react';
import React from 'react';
import { v4 as uuidv4 } from 'uuid';
import { SelectDropdown } from '../features/forms';
import Icon from './icon';
import { Select } from './ui';
import { HStack, Select } from './ui';
const List: React.FC = ({ children }) => (
<div className='space-y-0.5'>{children}</div>
@ -21,9 +21,15 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
const id = uuidv4();
const domId = `list-group-${id}`;
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onClick!();
}
};
const Comp = onClick ? 'a' : 'div';
const LabelComp = onClick ? 'span' : 'label';
const linkProps = onClick ? { onClick } : {};
const linkProps = onClick ? { onClick, onKeyDown, tabIndex: 0, role: 'link' } : {};
const renderChildren = React.useCallback(() => {
return React.Children.map(children, (child) => {
@ -50,7 +56,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
})}
{...linkProps}
>
<div className='flex flex-col py-1.5 pr-4'>
<div className='flex flex-col py-1.5 pr-4 rtl:pl-4 rtl:pr-0'>
<LabelComp className='text-gray-900 dark:text-gray-100' htmlFor={domId}>{label}</LabelComp>
{hint ? (
@ -59,11 +65,11 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
</div>
{onClick ? (
<div className='flex flex-row items-center text-gray-700 dark:text-gray-600'>
<HStack space={1} alignItems='center' className='text-gray-700 dark:text-gray-600'>
{children}
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1' />
</div>
</HStack>
) : renderChildren()}
</Comp>
);

View File

@ -1,77 +1,82 @@
.status-content p {
[data-markup] p {
@apply mb-4 whitespace-pre-wrap;
}
.status-content p:last-child {
[data-markup] p:last-child {
@apply mb-0;
}
.status-content a {
[data-markup] a {
@apply text-primary-600 dark:text-accent-blue hover:underline;
}
.status-content strong {
[data-markup] strong {
@apply font-bold;
}
.status-content em {
[data-markup] em {
@apply italic;
}
.status-content ul,
.status-content ol {
[data-markup] ul,
[data-markup] ol {
@apply pl-10 mb-4;
}
.status-content ul {
[data-markup] ul {
@apply list-disc list-outside;
}
.status-content ol {
[data-markup] ol {
@apply list-decimal list-outside;
}
.status-content blockquote {
[data-markup] blockquote {
@apply py-1 pl-4 mb-4 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400;
}
.status-content code {
[data-markup] code {
@apply cursor-text font-mono;
}
.status-content p > code,
.status-content pre {
[data-markup] p > code,
[data-markup] pre {
@apply bg-gray-100 dark:bg-primary-800;
}
/* Inline code */
.status-content p > code {
[data-markup] p > code {
@apply py-0.5 px-1 rounded-sm;
}
/* Code block */
.status-content pre {
[data-markup] pre {
@apply py-2 px-3 mb-4 leading-6 overflow-x-auto rounded-md break-all;
}
.status-content pre:last-child {
[data-markup] pre:last-child {
@apply mb-0;
}
/* Emojis */
[data-markup] img.emojione {
@apply w-5 h-5;
}
/* Markdown images */
.status-content img:not(.emojione):not([width][height]) {
[data-markup] img:not(.emojione):not([width][height]) {
@apply w-full h-72 object-contain rounded-lg overflow-hidden my-4 block;
}
/* User setting to underline links */
body.underline-links .status-content a {
body.underline-links [data-markup] a {
@apply underline;
}
.status-content .big-emoji img.emojione {
[data-markup].big-emoji img.emojione {
@apply inline w-9 h-9 p-1;
}
.status-content .status-link {
[data-markup] .status-link {
@apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue;
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import Text, { IText } from './ui/text/text';
import './markup.css';
interface IMarkup extends IText {
}
/** Styles HTML markup returned by the API, such as in account bios and statuses. */
const Markup = React.forwardRef<any, IMarkup>((props, ref) => {
return (
<Text ref={ref} {...props} data-markup />
);
});
export default Markup;

View File

@ -160,16 +160,22 @@ const Item: React.FC<IItem> = ({
</div>
);
} else if (attachment.type === 'image') {
const letterboxed = shouldLetterbox(attachment);
const letterboxed = total === 1 && shouldLetterbox(attachment);
thumbnail = (
<a
className={classNames('media-gallery__item-thumbnail', { letterboxed })}
className='media-gallery__item-thumbnail'
href={attachment.url}
onClick={handleClick}
target='_blank'
>
<StillImage src={attachment.url} alt={attachment.description} />
<StillImage
className='w-full h-full'
src={attachment.url}
alt={attachment.description}
letterboxed={letterboxed}
showExt
/>
</a>
);
} else if (attachment.type === 'gifv') {

View File

@ -106,7 +106,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
onMouseEnter={handleMouseEnter(dispatch)}
onMouseLeave={handleMouseLeave(dispatch)}
>
<Card variant='rounded' className='relative'>
<Card variant='rounded' className='relative isolate'>
<CardBody>
<Stack space={2}>
<BundleContainer fetchComponent={UserPanel}>
@ -136,7 +136,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
</HStack>
) : null}
{account.source.get('note', '').length > 0 && (
{account.note.length > 0 && (
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
)}
</Stack>

View File

@ -38,6 +38,7 @@ const messages = defineMessages({
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
interface ISidebarLink {
@ -82,7 +83,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
const features = useFeatures();
const getAccount = makeGetAccount();
const instance = useAppSelector((state) => state.instance);
const me = useAppSelector((state) => state.me);
const account = useAppSelector((state) => me ? getAccount(state, me) : null);
const otherAccounts: ImmutableList<AccountEntity> = useAppSelector((state) => getOtherAccounts(state));
@ -134,9 +134,11 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
if (!account) return null;
return (
<div className={classNames('sidebar-menu__root', {
<div
className={classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
})}
aria-expanded={sidebarOpen}
>
<div
className={classNames({
@ -147,7 +149,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
onClick={handleClose}
>
<IconButton
title='close'
title={intl.formatMessage(messages.close)}
onClick={handleClose}
src={require('@tabler/icons/x.svg')}
ref={closeButtonRef}
@ -220,15 +222,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
<SidebarLink
to='/timeline/local'
icon={features.federating ? require('@tabler/icons/users.svg') : require('@tabler/icons/world.svg')}
text={features.federating ? instance.title : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
icon={features.federating ? require('@tabler/icons/affiliate.svg') : require('@tabler/icons/world.svg')}
text={features.federating ? <FormattedMessage id='tabs_bar.local' defaultMessage='Local' /> : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/timeline/fediverse'
icon={require('assets/icons/fediverse.svg')}
icon={require('@tabler/icons/topology-star-ring-3.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
onClick={onClose}
/>

View File

@ -10,7 +10,7 @@ interface ISidebarNavigationLink {
/** URL to an SVG icon. */
icon: string,
/** Link label. */
text: React.ReactElement,
text: React.ReactNode,
/** Route to an internal page. */
to?: string,
/** Callback when the link is clicked. */
@ -37,7 +37,7 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
ref={ref}
onClick={handleClick}
className={classNames({
'flex items-center px-4 py-3.5 text-base font-semibold space-x-4 rounded-full group text-gray-600 hover:text-gray-900 dark:text-gray-500 dark:hover:text-gray-100 hover:bg-primary-200 dark:hover:bg-primary-900': true,
'flex items-center px-4 py-3.5 text-base font-semibold space-x-4 rtl:space-x-reverse rounded-full group text-gray-600 hover:text-gray-900 dark:text-gray-500 dark:hover:text-gray-100 hover:bg-primary-200 dark:hover:bg-primary-900': true,
'dark:text-gray-100 text-gray-900': isActive,
})}
>

View File

@ -16,9 +16,6 @@ const messages = defineMessages({
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' },
all: { id: 'tabs_bar.all', defaultMessage: 'All' },
fediverse: { id: 'tabs_bar.fediverse', defaultMessage: 'Fediverse' },
});
/** Desktop sidebar with links to different views in the app. */
@ -71,35 +68,6 @@ const SidebarNavigation = () => {
text: intl.formatMessage(messages.developers),
});
}
if (account.staff) {
menu.push({
to: '/soapbox/admin',
icon: require('@tabler/icons/dashboard.svg'),
text: intl.formatMessage(messages.dashboard),
count: dashboardCount,
});
}
if (features.publicTimeline) {
menu.push(null);
}
}
if (features.publicTimeline) {
menu.push({
to: '/timeline/local',
icon: features.federating ? require('@tabler/icons/users.svg') : require('@tabler/icons/world.svg'),
text: features.federating ? instance.title : intl.formatMessage(messages.all),
});
}
if (features.publicTimeline && features.federating) {
menu.push({
to: '/timeline/fediverse',
icon: require('assets/icons/fediverse.svg'),
text: intl.formatMessage(messages.fediverse),
});
}
return menu;
@ -170,6 +138,33 @@ const SidebarNavigation = () => {
icon={require('@tabler/icons/settings.svg')}
text={<FormattedMessage id='tabs_bar.settings' defaultMessage='Settings' />}
/>
{account.staff && (
<SidebarNavigationLink
to='/soapbox/admin'
icon={require('@tabler/icons/dashboard.svg')}
count={dashboardCount}
text={<FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />}
/>
)}
</>
)}
{features.publicTimeline && (
<>
<SidebarNavigationLink
to='/timeline/local'
icon={features.federating ? require('@tabler/icons/affiliate.svg') : require('@tabler/icons/world.svg')}
text={features.federating ? <FormattedMessage id='tabs_bar.local' defaultMessage='Local' /> : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
/>
{features.federating && (
<SidebarNavigationLink
to='/timeline/fediverse'
icon={require('@tabler/icons/topology-star-ring-3.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
/>
)}
</>
)}
@ -177,7 +172,6 @@ const SidebarNavigation = () => {
<DropdownMenu items={menu}>
<SidebarNavigationLink
icon={require('@tabler/icons/dots-circle-horizontal.svg')}
count={dashboardCount}
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
/>
</DropdownMenu>

View File

@ -14,7 +14,9 @@ interface ISiteLogo extends React.ComponentProps<'img'> {
const SiteLogo: React.FC<ISiteLogo> = ({ className, theme, ...rest }) => {
const { logo, logoDarkMode } = useSoapboxConfig();
const settings = useSettings();
const darkMode = useTheme() === 'dark';
let darkMode = useTheme() === 'dark';
if (theme === 'dark') darkMode = true;
/** Soapbox logo. */
const soapboxLogo = darkMode

View File

@ -1,4 +1,3 @@
import classNames from 'clsx';
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -16,9 +15,10 @@ import { initReport } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
import StatusActionButton from 'soapbox/components/status-action-button';
import { HStack } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { isLocal } from 'soapbox/utils/accounts';
import { isLocal, isRemote } from 'soapbox/utils/accounts';
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts';
import type { Menu } from 'soapbox/components/dropdown-menu';
@ -56,6 +56,7 @@ const messages = defineMessages({
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' },
group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' },
external: { id: 'status.external', defaultMessage: 'View post on {domain}' },
deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' },
deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' },
@ -127,8 +128,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
} else {
onOpenUnauthorizedModal('REPLY');
}
e.stopPropagation();
};
const handleShareClick = () => {
@ -146,18 +145,17 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
} else {
onOpenUnauthorizedModal('FAVOURITE');
}
e.stopPropagation();
};
const handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(toggleBookmark(status));
};
const handleReblogClick: React.EventHandler<React.MouseEvent> = e => {
e.stopPropagation();
const handleExternalClick = () => {
window.open(status.uri, '_blank');
};
const handleReblogClick: React.EventHandler<React.MouseEvent> = e => {
if (me) {
const modalReblog = () => dispatch(toggleReblog(status));
const boostModal = settings.get('boostModal');
@ -172,8 +170,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleQuoteClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
if (me) {
dispatch(quoteCompose(status));
} else {
@ -199,12 +195,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleDeleteClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
doDeleteStatus();
};
const handleRedraftClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
doDeleteStatus(true);
};
@ -213,35 +207,29 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(togglePin(status));
};
const handleMentionClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(mentionCompose(status.account as Account));
};
const handleDirectClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(directCompose(status.account as Account));
};
const handleChatClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
const account = status.account as Account;
dispatch(launchChat(account.id, history));
};
const handleMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(initMuteModal(status.account as Account));
};
const handleBlockClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
const account = status.get('account') as Account;
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/ban.svg'),
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.get('acct') }} />,
@ -257,7 +245,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleOpen: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
history.push(`/@${status.getIn(['account', 'acct'])}/posts/${status.id}`);
};
@ -269,12 +256,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleReport: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(initReport(status.account as Account, status));
};
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(toggleMuteStatus(status));
};
@ -282,8 +267,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const { uri } = status;
const textarea = document.createElement('textarea');
e.stopPropagation();
textarea.textContent = uri;
textarea.style.position = 'fixed';
@ -300,18 +283,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const onModerate: React.MouseEventHandler = (e) => {
e.stopPropagation();
const account = status.account as Account;
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
};
const handleDeleteStatus: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(deleteStatusModal(intl, status.id));
};
const handleToggleStatusSensitivity: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
};
@ -319,6 +299,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const mutingConversation = status.muted;
const ownAccount = status.getIn(['account', 'id']) === me;
const username = String(status.getIn(['account', 'username']));
const account = status.account as Account;
const domain = account.fqn.split('@')[1];
const menu: Menu = [];
@ -337,7 +319,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
icon: require('@tabler/icons/link.svg'),
});
if (features.embeds && isLocal(status.account as Account)) {
if (features.embeds && isLocal(account)) {
menu.push({
text: intl.formatMessage(messages.embed),
action: handleEmbed,
@ -358,6 +340,14 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
});
}
if (features.federating && isRemote(account)) {
menu.push({
text: intl.formatMessage(messages.external, { domain }),
action: handleExternalClick,
icon: require('@tabler/icons/external-link.svg'),
});
}
menu.push(null);
if (ownAccount || withDismiss) {
@ -550,12 +540,12 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const canShare = ('share' in navigator) && status.visibility === 'public';
return (
<div
data-testid='status-action-bar'
className={classNames('flex flex-row', {
'justify-between': space === 'expand',
'space-x-2': space === 'compact',
})}
<HStack data-testid='status-action-bar'>
<HStack
justifyContent={space === 'expand' ? 'between' : undefined}
space={space === 'compact' ? 2 : undefined}
grow={space === 'expand'}
onClick={e => e.stopPropagation()}
>
<StatusActionButton
title={replyTitle}
@ -617,7 +607,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
icon={require('@tabler/icons/dots.svg')}
/>
</DropdownMenuContainer>
</div>
</HStack>
</HStack>
);
};

View File

@ -10,19 +10,14 @@ import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content';
import { isRtl } from '../rtl';
import Markup from './markup';
import Poll from './polls/poll';
import './status-content.css';
import type { Status, Mention } from 'soapbox/types/entities';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
const BIG_EMOJI_LIMIT = 10;
type Point = [
x: number,
y: number,
]
interface IReadMoreButton {
onClick: React.MouseEventHandler,
}
@ -49,7 +44,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
const [collapsed, setCollapsed] = useState(false);
const [onlyEmoji, setOnlyEmoji] = useState(false);
const startXY = useRef<Point>();
const node = useRef<HTMLDivElement>(null);
const { greentext } = useSoapboxConfig();
@ -131,29 +125,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
updateStatusLinks();
});
const handleMouseDown: React.EventHandler<React.MouseEvent> = (e) => {
startXY.current = [e.clientX, e.clientY];
};
const handleMouseUp: React.EventHandler<React.MouseEvent> = (e) => {
if (!startXY.current) return;
const target = e.target as HTMLElement;
const parentNode = target.parentNode as HTMLElement;
const [startX, startY] = startXY.current;
const [deltaX, deltaY] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (target.localName === 'button' || target.localName === 'a' || (parentNode && (parentNode.localName === 'button' || parentNode.localName === 'a'))) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0 && !(e.ctrlKey || e.metaKey) && onClick) {
onClick();
}
startXY.current = undefined;
};
const parsedHtml = useMemo((): string => {
const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
@ -173,30 +144,24 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
const content = { __html: parsedHtml };
const directionStyle: React.CSSProperties = { direction: 'ltr' };
const className = classNames(baseClassName, 'status-content', {
const direction = isRtl(status.search_index) ? 'rtl' : 'ltr';
const className = classNames(baseClassName, {
'cursor-pointer': onClick,
'whitespace-normal': withSpoiler,
'max-h-[300px]': collapsed,
'leading-normal big-emoji': onlyEmoji,
});
if (isRtl(status.search_index)) {
directionStyle.direction = 'rtl';
}
if (onClick) {
const output = [
<div
<Markup
ref={node}
tabIndex={0}
key='content'
className={className}
style={directionStyle}
direction={direction}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
/>,
];
@ -212,14 +177,14 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
return <div className={classNames({ 'bg-gray-100 dark:bg-primary-800 rounded-md p-4': hasPoll })}>{output}</div>;
} else {
const output = [
<div
<Markup
ref={node}
tabIndex={0}
key='content'
className={classNames(baseClassName, 'status-content', {
className={classNames(baseClassName, {
'leading-normal big-emoji': onlyEmoji,
})}
style={directionStyle}
direction={direction}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>,

View File

@ -10,6 +10,7 @@ import { useAppDispatch, useSettings } from 'soapbox/hooks';
import { addAutoPlay } from 'soapbox/utils/media';
import type { List as ImmutableList } from 'immutable';
import type VideoType from 'soapbox/features/video';
import type { Status, Attachment } from 'soapbox/types/entities';
interface IStatusMedia {
@ -66,10 +67,6 @@ const StatusMedia: React.FC<IStatusMedia> = ({
dispatch(openModal('MEDIA', { media, status, index }));
};
const openVideo = (media: Attachment, time: number): void => {
dispatch(openModal('VIDEO', { media, time }));
};
if (size > 0 && firstAttachment) {
if (muted) {
media = (
@ -105,20 +102,17 @@ const StatusMedia: React.FC<IStatusMedia> = ({
);
} else {
media = (
<Bundle fetchComponent={Video} loading={renderLoadingVideoPlayer} >
{(Component: any) => (
<Bundle fetchComponent={Video} loading={renderLoadingVideoPlayer}>
{(Component: typeof VideoType) => (
<Component
preview={video.preview_url}
blurhash={video.blurhash}
src={video.url}
alt={video.description}
aspectRatio={video.meta.getIn(['original', 'aspect'])}
aspectRatio={Number(video.meta.getIn(['original', 'aspect']))}
height={285}
inline
sensitive={status.sensitive}
onOpenVideo={openVideo}
visible={showMedia}
onToggleVisibility={onToggleVisibility}
inline
/>
)}
</Bundle>
@ -173,7 +167,16 @@ const StatusMedia: React.FC<IStatusMedia> = ({
);
}
return media;
if (media) {
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onClick={e => e.stopPropagation()}>
{media}
</div>
);
} else {
return null;
}
};
export default StatusMedia;

View File

@ -66,7 +66,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
if (to.size > 2) {
accounts.push(
<span key='more' className='hover:underline cursor-pointer' role='presentation' onClick={handleOpenMentionsModal}>
<span key='more' className='hover:underline cursor-pointer' role='button' onClick={handleOpenMentionsModal} tabIndex={0}>
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />
</span>,
);

View File

@ -110,6 +110,11 @@ const Status: React.FC<IStatus> = (props) => {
const handleClick = (e?: React.MouseEvent): void => {
e?.stopPropagation();
// If the user is selecting text, don't focus the status.
if (getSelection()?.toString().length) {
return;
}
if (!e || !(e.ctrlKey || e.metaKey)) {
if (onClick) {
onClick();

View File

@ -12,10 +12,14 @@ interface IStillImage {
src: string,
/** Extra CSS styles on the outer <div> element. */
style?: React.CSSProperties,
/** Whether to display the image contained vs filled in its container. */
letterboxed?: boolean,
/** Whether to show the file extension in the corner. */
showExt?: boolean,
}
/** Renders images on a canvas, only playing GIFs if autoPlayGif is enabled. */
const StillImage: React.FC<IStillImage> = ({ alt, className, src, style }) => {
const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterboxed = false, showExt = false }) => {
const settings = useSettings();
const autoPlayGif = settings.get('autoPlayGif');
@ -34,10 +38,56 @@ const StillImage: React.FC<IStillImage> = ({ alt, className, src, style }) => {
}
};
/** ClassNames shared between the `<img>` and `<canvas>` elements. */
const baseClassName = classNames('w-full h-full block', {
'object-contain': letterboxed,
'object-cover': !letterboxed,
});
return (
<div data-testid='still-image-container' className={classNames(className, 'still-image', { 'still-image--play-on-hover': hoverToPlay })} style={style}>
<img src={src} alt={alt} ref={img} onLoad={handleImageLoad} />
{hoverToPlay && <canvas ref={canvas} />}
<div
data-testid='still-image-container'
className={classNames(className, 'relative group overflow-hidden isolate')}
style={style}
>
<img
src={src}
alt={alt}
ref={img}
onLoad={handleImageLoad}
className={classNames(baseClassName, {
'invisible group-hover:visible': hoverToPlay,
})}
/>
{hoverToPlay && (
<canvas
ref={canvas}
className={classNames(baseClassName, {
'group-hover:invisible': hoverToPlay,
})}
/>
)}
{(hoverToPlay && showExt) && (
<div className='group-hover:hidden absolute opacity-90 left-2 bottom-2 pointer-events-none'>
<ExtensionBadge ext='GIF' />
</div>
)}
</div>
);
};
interface IExtensionBadge {
/** File extension. */
ext: string,
}
/** Badge displaying a file extension. */
const ExtensionBadge: React.FC<IExtensionBadge> = ({ ext }) => {
return (
<div className='inline-flex items-center px-2 py-0.5 rounded text-sm font-medium bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'>
{ext}
</div>
);
};

View File

@ -50,7 +50,7 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
}
return (
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue text-left text-sm hover:underline' onClick={handleTranslate}>
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue text-start text-sm hover:underline' onClick={handleTranslate}>
<FormattedMessage id='status.translate' defaultMessage='Translate' />
</button>
);

View File

@ -1,5 +1,5 @@
import classNames from 'clsx';
import * as React from 'react';
import React from 'react';
import StillImage from 'soapbox/components/still-image';
@ -25,7 +25,7 @@ const Avatar = (props: IAvatar) => {
return (
<StillImage
className={classNames('rounded-full overflow-hidden', className)}
className={classNames('rounded-full', className)}
style={style}
src={src}
alt='Avatar'

View File

@ -1,5 +1,5 @@
import classNames from 'clsx';
import * as React from 'react';
import React from 'react';
import { Link } from 'react-router-dom';
import Icon from '../icon/icon';

View File

@ -3,7 +3,7 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Text } from 'soapbox/components/ui';
import { HStack, Text } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
const sizes = {
@ -62,7 +62,7 @@ const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }):
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
return (
<Comp {...backAttributes} className='mr-2 text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2' aria-label={intl.formatMessage(messages.back)}>
<Comp {...backAttributes} className='text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2' aria-label={intl.formatMessage(messages.back)}>
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6' />
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
</Comp>
@ -70,11 +70,11 @@ const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }):
};
return (
<div className='mb-4 flex flex-row items-center'>
<HStack alignItems='center' space={2} className='mb-4'>
{renderBackButton()}
{children}
</div>
</HStack>
);
};

View File

@ -47,8 +47,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = fa
return (
<HStack
space={2}
className={classNames('bg-white dark:bg-gray-900 p-3 rounded-full shadow-md z-[999] w-max')}
className={classNames('gap-2 bg-white dark:bg-gray-900 p-3 rounded-full shadow-md z-[999] w-max max-w-[100vw] flex-wrap')}
>
{Array.from(emojis).map((emoji, i) => (
<EmojiButton

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import Emoji from '../emoji';

View File

@ -1,10 +1,12 @@
import React from 'react';
import HStack from '../hstack/hstack';
/** Container element to house form actions. */
const FormActions: React.FC = ({ children }) => (
<div className='flex justify-end space-x-2'>
<HStack space={2} justifyContent='end'>
{children}
</div>
</HStack>
);
export default FormActions;

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
interface IForm {
/** Form submission event handler. */

View File

@ -21,13 +21,15 @@ const spaces = {
1: 'space-x-1',
1.5: 'space-x-1.5',
2: 'space-x-2',
2.5: 'space-x-2.5',
3: 'space-x-3',
4: 'space-x-4',
5: 'space-x-5',
6: 'space-x-6',
8: 'space-x-8',
};
interface IHStack {
interface IHStack extends Pick<React.HTMLAttributes<HTMLDivElement>, 'onClick'> {
/** Vertical alignment of children. */
alignItems?: keyof typeof alignItemsOptions
/** Extra class names on the <div> element. */
@ -58,7 +60,7 @@ const HStack = forwardRef<HTMLDivElement, IHStack>((props, ref) => {
<Elem
{...filteredProps}
ref={ref}
className={classNames('flex', {
className={classNames('flex rtl:space-x-reverse', {
// @ts-ignore
[alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined',
// @ts-ignore

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import SvgIcon from '../svg-icon';

View File

@ -35,6 +35,7 @@ export { default as RadioButton } from './radio-button/radio-button';
export { default as Select } from './select/select';
export { default as Spinner } from './spinner/spinner';
export { default as Stack } from './stack/stack';
export { default as Streamfield } from './streamfield/streamfield';
export { default as Tabs } from './tabs/tabs';
export { default as TagInput } from './tag-input/tag-input';
export { default as Text } from './text/text';

View File

@ -89,16 +89,15 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
'bg-transparent border-none': theme === 'transparent',
'pr-7': isPassword || append,
'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append,
'text-red-600 border-red-600': hasError,
'pl-8': typeof icon !== 'undefined',
'pl-16': typeof prepend !== 'undefined',
}, className)}
/>
{/* eslint-disable-next-line no-nested-ternary */}
{append ? (
<div className='absolute inset-y-0 right-0 flex items-center pr-3'>
<div className='absolute inset-y-0 right-0 rtl:left-0 rtl:right-auto flex items-center pr-3'>
{append}
</div>
) : null}
@ -111,7 +110,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
intl.formatMessage(messages.showPassword)
}
>
<div className='absolute inset-y-0 right-0 flex items-center'>
<div className='absolute inset-y-0 right-0 rtl:left-0 rtl:right-auto flex items-center'>
<button
type='button'
onClick={togglePassword}

View File

@ -1,9 +1,10 @@
import classNames from 'clsx';
import * as React from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from '../button/button';
import IconButton from '../icon-button/icon-button';
import Stack from '../stack/stack';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -81,7 +82,7 @@ const Modal: React.FC<IModal> = ({
}, [skipFocus, buttonRef]);
return (
<div data-testid='modal' className={classNames('block w-full p-6 mx-auto text-left align-middle transition-all transform bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-xl rounded-2xl pointer-events-auto', widths[width])}>
<div data-testid='modal' className={classNames('block w-full p-6 mx-auto text-start align-middle transition-all transform bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-xl rounded-2xl pointer-events-auto', widths[width])}>
<div className='sm:flex sm:items-start w-full justify-between'>
<div className='w-full'>
{title && (
@ -126,7 +127,7 @@ const Modal: React.FC<IModal> = ({
)}
</div>
<div className='flex flex-row space-x-2'>
<Stack space={2}>
{secondaryAction && (
<Button
theme='secondary'
@ -145,7 +146,7 @@ const Modal: React.FC<IModal> = ({
>
{confirmationText}
</Button>
</div>
</Stack>
</div>
)}
</div>

View File

@ -1,6 +1,8 @@
import React, { useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import HStack from '../hstack/hstack';
interface IRadioButton {
value: string
checked?: boolean
@ -16,7 +18,7 @@ const RadioButton: React.FC<IRadioButton> = ({ name, value, checked, onChange, l
const formFieldId: string = useMemo(() => `radio-${uuidv4()}`, []);
return (
<div className='flex items-center'>
<HStack alignItems='center' space={3}>
<input
type='radio'
name={name}
@ -27,10 +29,10 @@ const RadioButton: React.FC<IRadioButton> = ({ name, value, checked, onChange, l
className='h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500'
/>
<label htmlFor={formFieldId} className='ml-3 block text-sm font-medium text-gray-700'>
<label htmlFor={formFieldId} className='block text-sm font-medium text-gray-700'>
{label}
</label>
</div>
</HStack>
);
};

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
interface ISelect extends React.SelectHTMLAttributes<HTMLSelectElement> {
children: Iterable<React.ReactNode>,

View File

@ -6,7 +6,7 @@ import {
useTabsContext,
} from '@reach/tabs';
import classNames from 'clsx';
import * as React from 'react';
import React from 'react';
import { useHistory } from 'react-router-dom';
import Counter from '../counter/counter';

View File

@ -54,7 +54,9 @@ export type Sizes = keyof typeof sizes
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' | 'div'
type Directions = 'ltr' | 'rtl'
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML' | 'tabIndex' | 'lang'> {
/** Text content. */
children?: React.ReactNode,
/** How to align the text. */
align?: keyof typeof alignments,
/** Extra class names for the outer element. */
@ -84,8 +86,8 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
}
/** UI-friendly text container with dark mode support. */
const Text: React.FC<IText> = React.forwardRef(
(props: IText, ref: React.LegacyRef<any>) => {
const Text = React.forwardRef<any, IText>(
(props, ref) => {
const {
align,
className,

View File

@ -1,7 +1,7 @@
import classNames from 'clsx';
import React from 'react';
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows' | 'readOnly' | 'onKeyDown' | 'onPaste'> {
/** Put the cursor into the input on mount. */
autoFocus?: boolean,
/** The initial text in the input. */

View File

@ -1,8 +1,6 @@
import React from 'react';
import { Text, IconButton } from 'soapbox/components/ui';
import HStack from 'soapbox/components/ui/hstack/hstack';
import Stack from 'soapbox/components/ui/stack/stack';
import { HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
interface IWidgetTitle {
/** Title text for the widget. */

View File

@ -2,7 +2,7 @@ import classNames from 'clsx';
import React from 'react';
import { useIntl, defineMessages } from 'react-intl';
import Icon from 'soapbox/components/ui/icon/icon';
import { Icon } from 'soapbox/components/ui';
import { useSoapboxConfig } from 'soapbox/hooks';
const messages = defineMessages({

View File

@ -49,6 +49,8 @@ import ErrorBoundary from '../components/error-boundary';
import UI from '../features/ui';
import { store } from '../store';
const RTL_LOCALES = ['ar', 'ckb', 'fa', 'he'];
// Configure global functions for developers
createGlobals(store);
@ -276,7 +278,7 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
<>
<Helmet>
<html lang={locale} className={classNames('h-full', { dark: darkMode })} />
<body className={bodyClass} />
<body className={bodyClass} dir={RTL_LOCALES.includes(locale) ? 'rtl' : undefined} />
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
{darkMode && <style type='text/css'>{':root { color-scheme: dark; }'}</style>}
<meta name='theme-color' content={soapboxConfig.brandColor} />

View File

@ -74,6 +74,7 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, displayWidth, onOpenMedia
src={attachment.preview_url}
alt={attachment.description}
style={{ objectPosition: `${x}% ${y}%` }}
className='w-full h-full rounded-lg overflow-hidden'
/>
);
} else if (['gifv', 'video'].indexOf(attachment.type) !== -1) {

View File

@ -17,7 +17,7 @@ import { getSettings } from 'soapbox/actions/settings';
import snackbar from 'soapbox/actions/snackbar';
import Badge from 'soapbox/components/badge';
import StillImage from 'soapbox/components/still-image';
import { HStack, IconButton, Menu, MenuButton, MenuItem, MenuList, MenuLink, MenuDivider, Avatar } from 'soapbox/components/ui';
import { Avatar, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import MovedNote from 'soapbox/features/account-timeline/components/moved-note';
import ActionButton from 'soapbox/features/ui/components/action-button';
@ -67,6 +67,7 @@ const messages = defineMessages({
userEndorsed: { id: 'account.endorse.success', defaultMessage: 'You are now featuring @{acct} on your profile' },
userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' },
profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' },
header: { id: 'account.header.alt', defaultMessage: 'Profile header' },
});
interface IHeader {
@ -89,13 +90,13 @@ const Header: React.FC<IHeader> = ({ account }) => {
</div>
<div className='px-4 sm:px-6'>
<div className='-mt-12 flex items-end space-x-5'>
<HStack alignItems='bottom' space={5} className='-mt-12'>
<div className='flex relative'>
<div
className='h-24 w-24 bg-gray-400 rounded-full ring-4 ring-white dark:ring-gray-800'
/>
</div>
</div>
</HStack>
</div>
</div>
);
@ -551,13 +552,12 @@ const Header: React.FC<IHeader> = ({ account }) => {
)}
<div>
<div className='relative h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50'>
<div className='relative flex flex-col justify-center h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
{account.header && (
<a href={account.header} onClick={handleHeaderClick} target='_blank'>
<StillImage
src={account.header}
alt='Profile Header'
className='absolute inset-0 object-cover md:rounded-t-xl'
alt={intl.formatMessage(messages.header)}
/>
</a>
)}
@ -571,19 +571,19 @@ const Header: React.FC<IHeader> = ({ account }) => {
</div>
<div className='px-4 sm:px-6'>
<div className='-mt-12 flex items-end space-x-5'>
<HStack className='-mt-12' alignItems='bottom' space={5}>
<div className='flex'>
<a href={account.avatar} onClick={handleAvatarClick} target='_blank'>
<Avatar
src={account.avatar}
size={96}
className='h-24 w-24 rounded-full ring-4 ring-white dark:ring-primary-900'
className='relative h-24 w-24 rounded-full ring-4 ring-white dark:ring-primary-900'
/>
</a>
</div>
<div className='mt-6 flex justify-end w-full sm:pb-1'>
<div className='mt-10 flex flex-row space-y-0 space-x-2'>
<HStack space={2} className='mt-10'>
<SubscriptionButton account={account} />
{ownAccount && (
@ -607,13 +607,13 @@ const Header: React.FC<IHeader> = ({ account }) => {
return (
<Comp key={idx} {...itemProps} className='group'>
<div className='flex items-center'>
<HStack space={3} alignItems='center'>
{menuItem.icon && (
<SvgIcon src={menuItem.icon} className='mr-3 h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
<SvgIcon src={menuItem.icon} className='h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
)}
<div className='truncate'>{menuItem.text}</div>
</div>
</HStack>
</Comp>
);
}
@ -626,9 +626,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
{/* {renderMessageButton()} */}
<ActionButton account={account} />
</HStack>
</div>
</div>
</div>
</HStack>
</div>
</div>
);

View File

@ -2,8 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useState, useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { Stack, HStack, Card, Avatar, Text, Icon } from 'soapbox/components/ui';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import { Avatar, Card, HStack, Icon, IconButton, Stack, Text } from 'soapbox/components/ui';
import StatusCard from 'soapbox/features/status/components/card';
import { useAppSelector } from 'soapbox/hooks';

View File

@ -2,9 +2,9 @@ import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { addToAliases } from 'soapbox/actions/aliases';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display-name';
import AccountComponent from 'soapbox/components/account';
import IconButton from 'soapbox/components/icon-button';
import { HStack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
import { getFeatures } from 'soapbox/utils/features';
@ -47,23 +47,17 @@ const Account: React.FC<IAccount> = ({ accountId, aliases }) => {
if (!added && accountId !== me) {
button = (
<div className='account__relationship'>
<IconButton src={require('@tabler/icons/plus.svg')} title={intl.formatMessage(messages.add)} onClick={handleOnAdd} />
</div>
<IconButton src={require('@tabler/icons/plus.svg')} iconClassName='h-5 w-5' title={intl.formatMessage(messages.add)} onClick={handleOnAdd} />
);
}
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
<div className='w-full'>
<AccountComponent account={account} withRelationship={false} />
</div>
{button}
</div>
</div>
</HStack>
);
};

View File

@ -53,7 +53,7 @@ const Search: React.FC = () => {
placeholder={intl.formatMessage(messages.search)}
/>
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}>
<div role='button' tabIndex={hasValue ? 0 : -1} className='search__icon' onClick={handleClear}>
<Icon src={require('@tabler/icons/backspace.svg')} aria-label={intl.formatMessage(messages.search)} className={classNames('svg-icon--backspace', { active: hasValue })} />
</div>
</label>

View File

@ -449,6 +449,7 @@ const Audio: React.FC<IAudio> = (props) => {
onMouseLeave={handleMouseLeave}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={e => e.stopPropagation()}
>
<audio
src={src}

View File

@ -26,23 +26,26 @@ const LoginPage = () => {
const [mfaToken, setMfaToken] = useState(token || '');
const [shouldRedirect, setShouldRedirect] = useState(false);
const getFormData = (form: HTMLFormElement) => {
return Object.fromEntries(
const getFormData = (form: HTMLFormElement) =>
Object.fromEntries(
Array.from(form).map((i: any) => [i.name, i.value]),
);
};
const handleSubmit: React.FormEventHandler = (event) => {
const { username, password } = getFormData(event.target as HTMLFormElement);
dispatch(logIn(username, password)).then(({ access_token }) => {
return dispatch(verifyCredentials(access_token as string))
dispatch(logIn(username, password))
.then(({ access_token }) => dispatch(verifyCredentials(access_token as string)))
// Refetch the instance for authenticated fetch
.then(() => dispatch(fetchInstance() as any));
}).then((account: { id: string }) => {
.then(async (account) => {
await dispatch(fetchInstance());
return account;
})
.then((account: { id: string }) => {
dispatch(closeModal());
setShouldRedirect(true);
if (typeof me === 'string') {
dispatch(switchAccount(account.id));
} else {
setShouldRedirect(true);
}
}).catch((error: AxiosError) => {
const data: any = error.response?.data;

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Redirect } from 'react-router-dom';
@ -11,6 +11,7 @@ const token = new URLSearchParams(window.location.search).get('reset_password_to
const messages = defineMessages({
resetPasswordFail: { id: 'reset_password.fail', defaultMessage: 'Expired token, please try again.' },
passwordPlaceholder: { id: 'reset_password.password.placeholder', defaultMessage: 'Placeholder' },
});
const Statuses = {
@ -66,11 +67,11 @@ const PasswordResetConfirm = () => {
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Form onSubmit={handleSubmit}>
<FormGroup labelText='Password' errors={renderErrors()}>
<FormGroup labelText={<FormattedMessage id='reset_password.password.label' defaultMessage='Password' />} errors={renderErrors()}>
<Input
type='password'
name='password'
placeholder='Password'
placeholder={intl.formatMessage(messages.passwordPlaceholder)}
onChange={onChange}
required
/>

View File

@ -8,6 +8,7 @@ import {
} from 'soapbox/actions/chats';
import { uploadMedia } from 'soapbox/actions/media';
import IconButton from 'soapbox/components/icon-button';
import { Textarea } from 'soapbox/components/ui';
import UploadProgress from 'soapbox/components/upload-progress';
import UploadButton from 'soapbox/features/compose/components/upload-button';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
@ -179,11 +180,11 @@ const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef, autosize }) => {
{isUploading && (
<UploadProgress progress={uploadProgress * 100} />
)}
<div className='chat-box__actions simple_form'>
<div className='chat-box__actions'>
<div className='chat-box__send'>
{renderActionButton()}
</div>
<textarea
<Textarea
rows={1}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={handleKeyDown}

View File

@ -254,6 +254,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
items={menu}
src={require('@tabler/icons/dots.svg')}
title={intl.formatMessage(messages.more)}
dropdownMenuStyle={{ zIndex: 1000 }}
/>
</div>
</div>

View File

@ -1,4 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import {
@ -18,6 +19,10 @@ import ChatBox from './chat-box';
import type { Account as AccountEntity } from 'soapbox/types/entities';
const messages = defineMessages({
close: { id: 'chat_window.close', defaultMessage: 'Close chat' },
});
type WindowState = 'open' | 'minimized';
const getChat = makeGetChat();
@ -33,6 +38,7 @@ interface IChatWindow {
/** Floating desktop chat window. */
const ChatWindow: React.FC<IChatWindow> = ({ idx, chatId, windowState }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const displayFqn = useAppSelector(getDisplayFqn);
@ -98,7 +104,7 @@ const ChatWindow: React.FC<IChatWindow> = ({ idx, chatId, windowState }) => {
@{getAcct(account, displayFqn)}
</button>
<div className='pane__close'>
<IconButton src={require('@tabler/icons/x.svg')} title='Close chat' onClick={handleChatClose(chat.id)} />
<IconButton src={require('@tabler/icons/x.svg')} title={intl.formatMessage(messages.close)} onClick={handleChatClose(chat.id)} />
</div>
</HStack>
<div className='pane__content'>

View File

@ -1,10 +1,9 @@
import React, { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display-name';
import Icon from 'soapbox/components/icon';
import { Counter } from 'soapbox/components/ui';
import { Avatar, Counter, HStack, Stack, Text } from 'soapbox/components/ui';
import emojify from 'soapbox/features/emoji/emoji';
import { useAppSelector } from 'soapbox/hooks';
import { makeGetChat } from 'soapbox/selectors';
@ -34,37 +33,38 @@ const Chat: React.FC<IChat> = ({ chatId, onClick }) => {
return (
<div className='account'>
<button className='floating-link' onClick={() => onClick(chat)} />
<div className='account__wrapper'>
<div key={account.id} className='account__display-name'>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={36} />
</div>
<DisplayName account={account} />
<HStack key={account.id} space={3} className='relative overflow-hidden'>
<Avatar className='flex-none' src={account.avatar} size={36} />
<Stack className='overflow-hidden flex-1'>
<DisplayName account={account} withSuffix={false} />
<HStack space={1} justifyContent='between'>
{content ? (
<Text
theme='muted'
size='sm'
className='max-h-5'
dangerouslySetInnerHTML={{ __html: parsedContent }}
truncate
/>
) : attachment && (
<Text theme='muted' size='sm' className='italic'>
{image ? <FormattedMessage id='chats.attachment_image' defaultMessage='Image' /> : <FormattedMessage id='chats.attachment' defaultMessage='Attachment' />}
</Text>
)}
{attachment && (
<Icon
className='chat__attachment-icon'
src={image ? require('@tabler/icons/photo.svg') : require('@tabler/icons/paperclip.svg')}
/>
)}
{content ? (
<span
className='chat__last-message'
dangerouslySetInnerHTML={{ __html: parsedContent }}
/>
) : attachment && (
<span
className='chat__last-message attachment'
>
{image ? <FormattedMessage id='chats.attachment_image' defaultMessage='Image' /> : <FormattedMessage id='chats.attachment' defaultMessage='Attachment' />}
</span>
)}
</HStack>
{unreadCount > 0 && (
<div className='absolute top-1 right-0'>
<Counter count={unreadCount} />
</div>
)}
</div>
</div>
</Stack>
</HStack>
</div>
);
};

View File

@ -16,7 +16,7 @@ import {
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
import Icon from 'soapbox/components/icon';
import { Button, Stack } from 'soapbox/components/ui';
import { Button, HStack, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useCompose, useFeatures, usePrevious } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is-mobile';
@ -221,7 +221,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
}, [focusDate]);
const renderButtons = useCallback(() => (
<div className='flex items-center space-x-2'>
<HStack alignItems='center' space={2}>
{features.media && <UploadButtonContainer composeId={id} />}
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
{features.polls && <PollButton composeId={id} />}
@ -229,7 +229,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
{features.spoilers && <SpoilerButton composeId={id} />}
{features.richText && <MarkdownButton composeId={id} />}
</div>
</HStack>
), [features, id]);
const condensed = shouldCondense && !composeFocused && isEmpty() && !isUploading;
@ -335,16 +335,18 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
>
{renderButtons()}
<div className='flex items-center space-x-4 ml-auto'>
<HStack space={4} alignItems='center' className='ml-auto rtl:ml-0 rtl:mr-auto'>
{maxTootChars && (
<div className='flex items-center space-x-1'>
<HStack space={1} alignItems='center'>
<TextCharacterCounter max={maxTootChars} text={text} />
<VisualCharacterCounter max={maxTootChars} text={text} />
</div>
</HStack>
)}
<Button type='submit' theme='primary' text={publishText} disabled={disabledButton} />
</div>
</HStack>
{/* <HStack alignItems='center' space={4}>
</HStack> */}
</div>
</Stack>
);

View File

@ -4,7 +4,7 @@ import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { buildCustomEmojis } from '../../../emoji/emoji';
import { buildCustomEmojis, categoriesFromEmojis } from '../../../emoji/emoji';
import { EmojiPicker } from './emoji-picker-dropdown';
import ModifierPicker from './modifier-picker';
@ -14,19 +14,6 @@ import type { Emoji } from 'soapbox/components/autosuggest-emoji';
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = [
'recent',
'custom',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
@ -71,6 +58,20 @@ const EmojiPickerMenu: React.FC<IEmojiPickerMenu> = ({
const [modifierOpen, setModifierOpen] = useState(false);
const categoriesSort = [
'recent',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(customEmojis) as Set<string>).sort());
const handleDocumentClick = useCallback(e => {
if (node.current && !node.current.contains(e.target)) {
onClose();

View File

@ -13,7 +13,6 @@ import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
const messages = defineMessages({
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Answer #{number}' },
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add an answer' },
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this answer' },
pollDuration: { id: 'compose_form.poll.duration', defaultMessage: 'Duration' },
removePoll: { id: 'compose_form.poll.remove', defaultMessage: 'Remove poll' },
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple answers' },
@ -95,7 +94,9 @@ const Option: React.FC<IOption> = ({
{index > 1 && (
<div>
<Button theme='danger' size='sm' onClick={handleOptionRemove}>Delete</Button>
<Button theme='danger' size='sm' onClick={handleOptionRemove}>
<FormattedMessage id='compose_form.poll.remove_option' defaultMessage='Delete' />
</Button>
</div>
)}
</HStack>
@ -168,7 +169,7 @@ const PollForm: React.FC<IPollForm> = ({ composeId }) => {
<Divider />
<button type='button' onClick={handleToggleMultiple} className='text-left'>
<button type='button' onClick={handleToggleMultiple} className='text-start'>
<HStack alignItems='center' justifyContent='between'>
<Stack>
<Text weight='medium'>

View File

@ -149,7 +149,7 @@ const Search = (props: ISearch) => {
<div
role='button'
tabIndex={0}
className='absolute inset-y-0 right-0 px-3 flex items-center cursor-pointer'
className='absolute inset-y-0 right-0 rtl:left-0 rtl:right-auto px-3 flex items-center cursor-pointer'
onClick={handleClear}
>
<SvgIcon

View File

@ -33,7 +33,7 @@ const CryptoAddress: React.FC<ICryptoAddress> = (props): JSX.Element => {
<Stack>
<HStack alignItems='center' className='mb-1'>
<CryptoIcon
className='flex items-start justify-center w-6 mr-2.5'
className='flex items-start justify-center w-6 mr-2.5 rtl:ml-2.5 rtl:mr-0'
ticker={ticker}
title={title}
/>
@ -41,12 +41,12 @@ const CryptoAddress: React.FC<ICryptoAddress> = (props): JSX.Element => {
<Text weight='bold'>{title || ticker.toUpperCase()}</Text>
<HStack alignItems='center' className='ml-auto'>
<a className='text-gray-500 ml-1' href='#' onClick={handleModalClick}>
<a className='text-gray-500 ml-1 rtl:ml-0 rtl:mr-1' href='#' onClick={handleModalClick}>
<Icon src={require('@tabler/icons/qrcode.svg')} size={20} />
</a>
{explorerUrl && (
<a className='text-gray-500 ml-1' href={explorerUrl} target='_blank'>
<a className='text-gray-500 ml-1 rtl:ml-0 rtl:mr-1' href={explorerUrl} target='_blank'>
<Icon src={require('@tabler/icons/external-link.svg')} size={20} />
</a>
)}

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { deleteAccount } from 'soapbox/actions/security';

View File

@ -29,6 +29,7 @@ const isJSONValid = (text: any): boolean => {
const messages = defineMessages({
heading: { id: 'column.settings_store', defaultMessage: 'Settings store' },
advanced: { id: 'developers.settings_store.advanced', defaultMessage: 'Advanced settings' },
hint: { id: 'developers.settings_store.hint', defaultMessage: 'It is possible to directly edit your user settings here. BE CAREFUL! Editing this section can break your account, and you will only be able to recover through the API.' },
});
@ -98,7 +99,7 @@ const SettingsStore: React.FC = () => {
</Form>
<CardHeader>
<CardTitle title='Advanced settings' />
<CardTitle title={intl.formatMessage(messages.advanced)} />
</CardHeader>
<List>

View File

@ -5,14 +5,13 @@ import { directComposeById } from 'soapbox/actions/compose';
import { connectDirectStream } from 'soapbox/actions/streaming';
import { expandDirectTimeline } from 'soapbox/actions/timelines';
import AccountSearch from 'soapbox/components/account-search';
import ColumnHeader from 'soapbox/components/column-header';
import { Column } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { useAppDispatch } from 'soapbox/hooks';
import Timeline from '../ui/components/timeline';
const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
heading: { id: 'column.direct', defaultMessage: 'Direct messages' },
searchPlaceholder: { id: 'direct.search_placeholder', defaultMessage: 'Send a message to…' },
});
@ -20,8 +19,6 @@ const DirectTimeline = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const hasUnread = useAppSelector((state) => (state.timelines.get('direct')?.unread || 0) > 0);
useEffect(() => {
dispatch(expandDirectTimeline());
const disconnect = dispatch(connectDirectStream());
@ -40,13 +37,7 @@ const DirectTimeline = () => {
};
return (
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
<ColumnHeader
icon='envelope'
active={hasUnread}
title={intl.formatMessage(messages.title)}
/>
<Column label={intl.formatMessage(messages.heading)}>
<AccountSearch
placeholder={intl.formatMessage(messages.searchPlaceholder)}
onSelected={handleSuggestion}
@ -57,7 +48,7 @@ const DirectTimeline = () => {
timelineId='direct'
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
divideType='space'
divideType='border'
/>
</Column>
);

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeEmail } from 'soapbox/actions/security';

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changePassword } from 'soapbox/actions/security';

View File

@ -1,7 +1,7 @@
import React from 'react';
import StillImage from 'soapbox/components/still-image';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge';
import { useSoapboxConfig } from 'soapbox/hooks';
@ -17,25 +17,28 @@ const ProfilePreview: React.FC<IProfilePreview> = ({ account }) => {
return (
<div className='bg-white dark:bg-gray-800 rounded-lg text-black dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden'>
<div>
<div className='relative w-full h-32 md:rounded-t-lg bg-gray-200 dark:bg-gray-900/50'>
<StillImage alt='' src={account.header} className='absolute inset-0 object-cover md:rounded-t-lg' />
</div>
<div className='relative overflow-hidden isolate w-full h-32 md:rounded-t-lg bg-gray-200 dark:bg-gray-900/50'>
<StillImage src={account.header} />
</div>
<HStack space={3} alignItems='center' className='p-3'>
<div className='relative'>
<div className='h-12 w-12 bg-gray-400 rounded-full'>
<StillImage alt='' className='h-12 w-12 rounded-full' src={account.avatar} />
</div>
<Avatar className='bg-gray-400' src={account.avatar} />
{account.verified && <div className='absolute -top-1.5 -right-1.5'><VerificationBadge /></div>}
{account.verified && (
<div className='absolute -top-1.5 -right-1.5'>
<VerificationBadge />
</div>
)}
</div>
<Stack className='truncate'>
<Text weight='medium' size='sm' truncate>
{account.display_name}
</Text>
<Text
weight='medium'
size='sm'
truncate
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
<Text theme='muted' size='sm'>@{displayFqn ? account.fqn : account.acct}</Text>
</Stack>
</HStack>

View File

@ -15,16 +15,17 @@ import {
FormGroup,
HStack,
Input,
Streamfield,
Textarea,
Toggle,
} from 'soapbox/components/ui';
import Streamfield, { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks';
import { normalizeAccount } from 'soapbox/normalizers';
import resizeImage from 'soapbox/utils/resize-image';
import ProfilePreview from './components/profile-preview';
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
import type { Account } from 'soapbox/types/entities';
/**
@ -198,7 +199,10 @@ const EditProfile: React.FC = () => {
const handleSubmit: React.FormEventHandler = (event) => {
const promises = [];
promises.push(dispatch(patchMe(data, true)));
const params = { ...data };
if (params.fields_attributes?.length === 0) params.fields_attributes = [{ name: '', value: '' }];
promises.push(dispatch(patchMe(params, true)));
if (features.muteStrangers) {
promises.push(

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Redirect } from 'react-router-dom';

View File

@ -88,5 +88,10 @@ describe('emoji', () => {
expect(emojify('💂‍♀️💂‍♂️'))
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀" title=":female-guard:" src="/packs/emoji/1f482-200d-2640-fe0f.svg"><img draggable="false" class="emojione" alt="💂\u200D♂" title=":male-guard:" src="/packs/emoji/1f482-200d-2642-fe0f.svg">');
});
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>'))
.toEqual('<p><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/packs/emoji/1f495.svg"> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>');
});
});
});

View File

@ -6,8 +6,6 @@ import unicodeMapping from './emoji-unicode-mapping-light';
const trie = new Trie(Object.keys(unicodeMapping));
const domParser = new DOMParser();
const emojifyTextNode = (node, customEmojis, autoPlayGif = false) => {
let str = node.textContent;
@ -26,7 +24,7 @@ const emojifyTextNode = (node, customEmojis, autoPlayGif = false) => {
}
}
let rend, replacement = '';
let rend, replacement = null;
if (i === str.length) {
break;
} else if (str[i] === ':') {
@ -39,7 +37,14 @@ const emojifyTextNode = (node, customEmojis, autoPlayGif = false) => {
// if you want additional emoji handler, add statements below which set replacement and return true.
if (shortname in customEmojis) {
const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
replacement = `<img draggable="false" class="emojione custom-emoji" alt="${shortname}" title="${shortname}" src="${filename}" data-original="${customEmojis[shortname].url}" data-static="${customEmojis[shortname].static_url}" />`;
replacement = document.createElement('img');
replacement.setAttribute('draggable', false);
replacement.setAttribute('class', 'emojione custom-emoji');
replacement.setAttribute('alt', shortname);
replacement.setAttribute('title', shortname);
replacement.setAttribute('src', filename);
replacement.setAttribute('data-original', customEmojis[shortname].url);
replacement.setAttribute('data-static', customEmojis[shortname].static_url);
return true;
}
return false;
@ -47,8 +52,12 @@ const emojifyTextNode = (node, customEmojis, autoPlayGif = false) => {
} else { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match];
const title = shortCode ? `:${shortCode}:` : '';
const src = joinPublicPath(`packs/emoji/${filename}.svg`);
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${src}" />`;
replacement = document.createElement('img');
replacement.setAttribute('draggable', false);
replacement.setAttribute('class', 'emojione');
replacement.setAttribute('alt', match);
replacement.setAttribute('title', title);
replacement.setAttribute('src', joinPublicPath(`packs/emoji/${filename}.svg`));
rend = i + match.length;
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
if (str.codePointAt(rend) === 65038) {
@ -58,7 +67,7 @@ const emojifyTextNode = (node, customEmojis, autoPlayGif = false) => {
fragment.append(document.createTextNode(str.slice(0, i)));
if (replacement) {
fragment.append(domParser.parseFromString(replacement, 'text/html').documentElement.getElementsByTagName('img')[0]);
fragment.append(replacement);
}
node.textContent = str.slice(0, i);
str = str.slice(rend);

View File

@ -4,7 +4,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon';
import { Text } from 'soapbox/components/ui';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import type { Map as ImmutableMap } from 'immutable';
@ -16,6 +16,23 @@ const hasRestrictions = (remoteInstance: ImmutableMap<string, any>): boolean =>
.reduce((acc: boolean, value: boolean) => acc || value, false);
};
interface IRestriction {
icon: string,
children: React.ReactNode,
}
const Restriction: React.FC<IRestriction> = ({ icon, children }) => {
return (
<HStack space={3}>
<Icon className='flex-none w-5 h-5' src={icon} />
<Text theme='muted'>
{children}
</Text>
</HStack>
);
};
interface IInstanceRestrictions {
remoteInstance: ImmutableMap<string, any>,
}
@ -40,57 +57,52 @@ const InstanceRestrictions: React.FC<IInstanceRestrictions> = ({ remoteInstance
if (followers_only) {
items.push((
<Text key='followers_only' className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/lock.svg')} />
<Restriction key='followersOnly' icon={require('@tabler/icons/lock.svg')}>
<FormattedMessage
id='federation_restriction.followers_only'
defaultMessage='Hidden except to followers'
/>
</Text>
</Restriction>
));
} else if (federated_timeline_removal) {
items.push((
<Text key='federated_timeline_removal' className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/lock-open.svg')} />
<Restriction key='federatedTimelineRemoval' icon={require('@tabler/icons/lock-open.svg')}>
<FormattedMessage
id='federation_restriction.federated_timeline_removal'
defaultMessage='Fediverse timeline removal'
/>
</Text>
</Restriction>
));
}
if (fullMediaRemoval) {
items.push((
<Text key='full_media_removal' className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/photo-off.svg')} />
<Restriction key='fullMediaRemoval' icon={require('@tabler/icons/photo-off.svg')}>
<FormattedMessage
id='federation_restriction.full_media_removal'
defaultMessage='Full media removal'
/>
</Text>
</Restriction>
));
} else if (partialMediaRemoval) {
items.push((
<Text key='partial_media_removal' className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/photo-off.svg')} />
<Restriction key='partialMediaRemoval' icon={require('@tabler/icons/photo-off.svg')}>
<FormattedMessage
id='federation_restriction.partial_media_removal'
defaultMessage='Partial media removal'
/>
</Text>
</Restriction>
));
}
if (!fullMediaRemoval && media_nsfw) {
items.push((
<Text key='media_nsfw' className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/eye-off.svg')} />
<Restriction key='mediaNsfw' icon={require('@tabler/icons/eye-off.svg')}>
<FormattedMessage
id='federation_restriction.media_nsfw'
defaultMessage='Attachments marked NSFW'
/>
</Text>
</Restriction>
));
}
@ -105,46 +117,45 @@ const InstanceRestrictions: React.FC<IInstanceRestrictions> = ({ remoteInstance
if (remoteInstance.getIn(['federation', 'reject']) === true) {
return (
<Text className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/x.svg')} />
<Restriction icon={require('@tabler/icons/shield-x.svg')}>
<FormattedMessage
id='remote_instance.federation_panel.restricted_message'
defaultMessage='{siteTitle} blocks all activities from {host}.'
values={{ host, siteTitle }}
/>
</Text>
</Restriction>
);
} else if (hasRestrictions(remoteInstance)) {
return [
(
<Text theme='muted'>
return (
<>
<Restriction icon={require('@tabler/icons/shield-lock.svg')}>
<FormattedMessage
id='remote_instance.federation_panel.some_restrictions_message'
defaultMessage='{siteTitle} has placed some restrictions on {host}.'
values={{ host, siteTitle }}
/>
</Text>
),
renderRestrictions(),
];
</Restriction>
{renderRestrictions()}
</>
);
} else {
return (
<Text className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/check.svg')} />
<Restriction icon={require('@tabler/icons/shield-check.svg')}>
<FormattedMessage
id='remote_instance.federation_panel.no_restrictions_message'
defaultMessage='{siteTitle} has placed no restrictions on {host}.'
values={{ host, siteTitle }}
/>
</Text>
</Restriction>
);
}
};
return (
<div className='py-1 pl-4 mb-4 border-solid border-l-[3px] border-gray-300 dark:border-gray-500'>
<Stack space={3}>
{renderContent()}
</div>
</Stack>
);
};

View File

@ -35,7 +35,7 @@ const SuggestionItem = ({ accountId }: { accountId: string }) => {
<HStack alignItems='center' justifyContent='center' space={1}>
<Text
weight='semibold'
dangerouslySetInnerHTML={{ __html: account.display_name }}
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
truncate
align='center'
size='sm'
@ -78,7 +78,7 @@ const FeedSuggestions = () => {
</HStack>
<CardBody>
<HStack alignItems='center' className='overflow-x-auto lg:overflow-x-hidden space-x-4 md:space-x-0'>
<HStack space={4} alignItems='center' className='overflow-x-auto lg:overflow-x-hidden md:space-x-0'>
{suggestedProfiles.slice(0, 4).map((suggestedProfile) => (
<SuggestionItem key={suggestedProfile.account} accountId={suggestedProfile.account} />
))}

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import LandingPage from '..';
import { rememberInstance } from '../../../actions/instance';

View File

@ -1,15 +1,14 @@
import * as React from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { prepareRequest } from 'soapbox/actions/consumer-auth';
import Markup from 'soapbox/components/markup';
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge';
import RegistrationForm from 'soapbox/features/auth-login/components/registration-form';
import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import { capitalize } from 'soapbox/utils/strings';
import './instance-description.css';
const LandingPage = () => {
const dispatch = useAppDispatch();
const features = useFeatures();
@ -107,19 +106,17 @@ const LandingPage = () => {
<main className='mt-16 sm:mt-24' data-testid='homepage'>
<div className='mx-auto max-w-7xl'>
<div className='grid grid-cols-1 lg:grid-cols-12 gap-8 py-12'>
<div className='px-4 sm:px-6 sm:text-center md:max-w-2xl md:mx-auto lg:col-span-6 lg:text-left lg:flex'>
<div className='px-4 sm:px-6 sm:text-center md:max-w-2xl md:mx-auto lg:col-span-6 lg:text-start lg:flex'>
<div className='w-full'>
<Stack space={3}>
<h1 className='text-5xl font-extrabold text-transparent text-ellipsis overflow-hidden bg-clip-text bg-gradient-to-br from-accent-500 via-primary-500 to-gradient-end sm:mt-5 sm:leading-none lg:mt-6 lg:text-6xl xl:text-7xl'>
{instance.title}
</h1>
<Text size='lg'>
<span
className='instance-description'
<Markup
size='lg'
dangerouslySetInnerHTML={{ __html: instance.short_description || instance.description }}
/>
</Text>
</Stack>
</div>
</div>

View File

@ -1,14 +0,0 @@
/* Instance HTML from the API. */
.instance-description a {
@apply underline;
}
.instance-description b,
.instance-description strong {
@apply font-bold;
}
.instance-description i,
.instance-description em {
@apply italic;
}

View File

@ -1,31 +0,0 @@
import React, { useCallback } from 'react';
import DisplayName from 'soapbox/components/display-name';
import { Avatar } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
interface IAccount {
accountId: string,
}
const Account: React.FC<IAccount> = ({ accountId }) => {
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, accountId));
if (!account) return null;
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar src={account.avatar} size={36} /></div>
<DisplayName account={account} />
</div>
</div>
</div>
);
};
export default Account;

View File

@ -4,11 +4,11 @@ import { createSelector } from 'reselect';
import { setupListAdder, resetListAdder } from 'soapbox/actions/lists';
import { CardHeader, CardTitle, Modal } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import NewListForm from '../lists/components/new-list-form';
import Account from './components/account';
import List from './components/list';
import type { List as ImmutableList } from 'immutable';
@ -58,7 +58,7 @@ const ListAdder: React.FC<IListAdder> = ({ accountId, onClose }) => {
title={<FormattedMessage id='list_adder.header_title' defaultMessage='Add or Remove from Lists' />}
onClose={onClickClose}
>
<Account accountId={accountId} />
<AccountContainer id={accountId} withRelationship={false} />
<br />

View File

@ -1,12 +1,11 @@
import React, { useCallback } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { removeFromListEditor, addToListEditor } from 'soapbox/actions/lists';
import DisplayName from 'soapbox/components/display-name';
import IconButton from 'soapbox/components/icon-button';
import { Avatar } from 'soapbox/components/ui';
import { HStack } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
@ -20,37 +19,27 @@ interface IAccount {
const Account: React.FC<IAccount> = ({ accountId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, accountId));
const isAdded = useAppSelector((state) => state.listEditor.accounts.items.includes(accountId));
const onRemove = () => dispatch(removeFromListEditor(accountId));
const onAdd = () => dispatch(addToListEditor(accountId));
if (!account) return null;
let button;
if (isAdded) {
button = <IconButton src={require('@tabler/icons/x.svg')} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
button = <IconButton src={require('@tabler/icons/x.svg')} iconClassName='h-5 w-5' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton src={require('@tabler/icons/plus.svg')} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
button = <IconButton src={require('@tabler/icons/plus.svg')} iconClassName='h-5 w-5' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar src={account.avatar} size={36} /></div>
<DisplayName account={account} />
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
<div className='w-full'>
<AccountContainer id={accountId} withRelationship={false} />
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
</HStack>
);
};

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { updateNotifications } from 'soapbox/actions/notifications';
import { render, screen, rootState, createTestStore } from 'soapbox/jest/test-helpers';

View File

@ -1,5 +1,5 @@
import classNames from 'clsx';
import * as React from 'react';
import React from 'react';
import { useDispatch } from 'react-redux';
import ReactSwipeableViews from 'react-swipeable-views';

View File

@ -1,6 +1,6 @@
import classNames from 'clsx';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import React from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
@ -11,6 +11,10 @@ import resizeImage from 'soapbox/utils/resize-image';
import type { AxiosError } from 'axios';
const messages = defineMessages({
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
/** Default avatar filenames from various backends */
const DEFAULT_AVATARS = [
'/avatars/original/missing.png', // Mastodon
@ -64,7 +68,7 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
if (error.response?.status === 422) {
dispatch(snackbar.error((error.response.data as any).error.replace('Validation failed: ', '')));
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
dispatch(snackbar.error(messages.error));
}
});
}).catch(console.error);

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
@ -9,7 +9,13 @@ import { useOwnAccount } from 'soapbox/hooks';
import type { AxiosError } from 'axios';
const messages = defineMessages({
bioPlaceholder: { id: 'onboarding.bio.placeholder', defaultMessage: 'Tell the world a little about yourself…' },
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
const BioStep = ({ onNext }: { onNext: () => void }) => {
const intl = useIntl();
const dispatch = useDispatch();
const account = useOwnAccount();
@ -32,7 +38,7 @@ const BioStep = ({ onNext }: { onNext: () => void }) => {
if (error.response?.status === 422) {
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
dispatch(snackbar.error(messages.error));
}
});
};
@ -56,13 +62,13 @@ const BioStep = ({ onNext }: { onNext: () => void }) => {
<Stack space={5}>
<div className='sm:pt-10 sm:w-2/3 mx-auto'>
<FormGroup
hintText='Max 500 characters'
labelText='Bio'
hintText={<FormattedMessage id='onboarding.bio.hint' defaultMessage='Max 500 characters' />}
labelText={<FormattedMessage id='edit_profile.fields.bio_label' defaultMessage='Bio' />}
errors={errors}
>
<Textarea
onChange={(event) => setValue(event.target.value)}
placeholder='Tell the world a little about yourself…'
placeholder={intl.formatMessage(messages.bioPlaceholder)}
value={value}
maxLength={500}
/>

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Button, Card, CardBody, Icon, Stack, Text } from 'soapbox/components/ui';

View File

@ -1,6 +1,6 @@
import classNames from 'clsx';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
@ -12,6 +12,11 @@ import resizeImage from 'soapbox/utils/resize-image';
import type { AxiosError } from 'axios';
const messages = defineMessages({
header: { id: 'account.header.alt', defaultMessage: 'Profile header' },
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
/** Default header filenames from various backends */
const DEFAULT_HEADERS = [
'/headers/original/missing.png', // Mastodon
@ -24,6 +29,7 @@ const isDefaultHeader = (url: string) => {
};
const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
const intl = useIntl();
const dispatch = useDispatch();
const account = useOwnAccount();
@ -65,7 +71,7 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
if (error.response?.status === 422) {
dispatch(snackbar.error((error.response.data as any).error.replace('Validation failed: ', '')));
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
dispatch(snackbar.error(messages.error));
}
});
}).catch(console.error);
@ -90,7 +96,6 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Stack space={10}>
<div className='border border-solid border-gray-200 dark:border-gray-800 rounded-lg'>
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
<div
role='button'
className='relative h-24 bg-gray-200 dark:bg-gray-800 rounded-t-md flex items-center justify-center'
@ -98,7 +103,7 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
{selectedFile || account?.header && (
<StillImage
src={selectedFile || account.header}
alt='Profile Header'
alt={intl.formatMessage(messages.header)}
className='absolute inset-0 object-cover rounded-t-md'
/>
)}
@ -138,7 +143,11 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
<Stack justifyContent='center' space={2}>
<Button block theme='primary' type='button' onClick={onNext} disabled={isDefault && isDisabled || isSubmitting}>
{isSubmitting ? 'Saving…' : 'Next'}
{isSubmitting ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
) : (
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
)}
</Button>
{isDisabled && (

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
@ -9,7 +9,13 @@ import { useOwnAccount } from 'soapbox/hooks';
import type { AxiosError } from 'axios';
const messages = defineMessages({
usernamePlaceholder: { id: 'onboarding.display_name.placeholder', defaultMessage: 'Eg. John Smith' },
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
const intl = useIntl();
const dispatch = useDispatch();
const account = useOwnAccount();
@ -43,7 +49,7 @@ const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
if (error.response?.status === 422) {
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
dispatch(snackbar.error(messages.error));
}
});
};
@ -68,12 +74,12 @@ const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
<Stack space={5}>
<FormGroup
hintText={hintText}
labelText='Display name'
labelText={<FormattedMessage id='onboarding.display_name.label' defaultMessage='Display name' />}
errors={errors}
>
<Input
onChange={(event) => setValue(event.target.value)}
placeholder='Eg. John Smith'
placeholder={intl.formatMessage(messages.usernamePlaceholder)}
type='text'
value={value}
maxLength={30}
@ -88,7 +94,11 @@ const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
disabled={isDisabled || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? 'Saving…' : 'Next'}
{isSubmitting ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
) : (
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
)}
</Button>
<Button block theme='tertiary' type='button' onClick={onNext}>

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Account from 'soapbox/components/account';

View File

@ -1,5 +1,5 @@
import debounce from 'lodash/debounce';
import * as React from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import ScrollableList from 'soapbox/components/scrollable-list';

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import { Stack } from 'soapbox/components/ui';

View File

@ -1,5 +1,5 @@
import classNames from 'clsx';
import * as React from 'react';
import React from 'react';
import { randomIntFromInterval, generateText } from '../utils';

Some files were not shown because too many files have changed in this diff Show More