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. The change will take effect immediately, just refresh your browser tab.
It's not necessary to restart the Pleroma service. 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). 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 ## :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 # Custom icons
- fediverse.svg - Modified from Wikipedia, CC0
- verified.svg - Created by Alex Gleason. CC0 - verified.svg - Created by Alex Gleason. CC0
Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg 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.add_option": "Add a choice",
"compose_form.poll.duration": "Poll duration", "compose_form.poll.duration": "Poll duration",
"compose_form.poll.option_placeholder": "Choice {number}", "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.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.",
"compose_form.publish": "Publish", "compose_form.publish": "Publish",
"compose_form.publish_loud": "{publish}!", "compose_form.publish_loud": "{publish}!",
@ -566,7 +566,7 @@
"compose_form.poll.add_option": "Add a choice", "compose_form.poll.add_option": "Add a choice",
"compose_form.poll.duration": "Poll duration", "compose_form.poll.duration": "Poll duration",
"compose_form.poll.option_placeholder": "Choice {number}", "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.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.",
"compose_form.publish": "Publish", "compose_form.publish": "Publish",
"compose_form.publish_loud": "{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 { startOnboarding } from 'soapbox/actions/onboarding';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { custom } from 'soapbox/custom'; import { custom } from 'soapbox/custom';
import { queryClient } from 'soapbox/queries/client';
import KVStore from 'soapbox/storage/kv-store'; import KVStore from 'soapbox/storage/kv-store';
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth'; import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
@ -239,8 +240,14 @@ export const logOut = () =>
token: state.auth.getIn(['users', account.url, 'access_token']), 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 }); dispatch({ type: AUTH_LOGGED_OUT, account, standalone });
return dispatch(snackbar.success(messages.loggedOut)); return dispatch(snackbar.success(messages.loggedOut));
}); });
}; };
@ -248,6 +255,10 @@ export const logOut = () =>
export const switchAccount = (accountId: string, background = false) => export const switchAccount = (accountId: string, background = false) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const account = getState().accounts.get(accountId); const account = getState().accounts.get(accountId);
// Clear all stored cache from React Query
queryClient.invalidateQueries();
queryClient.clear();
return dispatch({ type: SWITCH_ACCOUNT, account, background }); return dispatch({ type: SWITCH_ACCOUNT, account, background });
}; };

View File

@ -1,4 +1,4 @@
import * as React from 'react'; import React from 'react';
interface IInlineSVG { interface IInlineSVG {
loader?: JSX.Element, 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 { Link, useHistory } from 'react-router-dom';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
@ -199,7 +199,7 @@ const Account = ({
title={account.acct} title={account.acct}
onClick={(event: React.MouseEvent) => event.stopPropagation()} 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 <Text
size='sm' size='sm'
weight='semibold' weight='semibold'
@ -208,7 +208,7 @@ const Account = ({
/> />
{account.verified && <VerificationBadge />} {account.verified && <VerificationBadge />}
</div> </HStack>
</LinkEl> </LinkEl>
</ProfilePopper> </ProfilePopper>
@ -255,7 +255,7 @@ const Account = ({
<Text <Text
size='sm' size='sm'
dangerouslySetInnerHTML={{ __html: account.note_emojified }} dangerouslySetInnerHTML={{ __html: account.note_emojified }}
className='mr-2' className='mr-2 rtl:ml-2 rtl:mr-0'
/> />
)} )}
</Stack> </Stack>

View File

@ -28,7 +28,7 @@ const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
ref={input} ref={input}
type='text' type='text'
value={value} value={value}
className='rounded-r-none' className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg'
outerClassName='flex-grow' outerClassName='flex-grow'
onClick={selectInput} onClick={selectInput}
readOnly readOnly
@ -36,7 +36,7 @@ const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
<Button <Button
theme='primary' 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} onClick={selectInput}
> >
<FormattedMessage id='input.copy' defaultMessage='Copy' /> <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 HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import { useSoapboxConfig } from 'soapbox/hooks'; import { useSoapboxConfig } from 'soapbox/hooks';
@ -7,16 +7,18 @@ import { getAcct } from '../utils/accounts';
import Icon from './icon'; import Icon from './icon';
import RelativeTimestamp from './relative-timestamp'; import RelativeTimestamp from './relative-timestamp';
import { HStack, Text } from './ui';
import VerificationBadge from './verification-badge'; import VerificationBadge from './verification-badge';
import type { Account } from 'soapbox/types/entities'; import type { Account } from 'soapbox/types/entities';
interface IDisplayName { interface IDisplayName {
account: Account account: Account
withSuffix?: boolean
withDate?: 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 { displayFqn = false } = useSoapboxConfig();
const { created_at: createdAt, verified } = account; const { created_at: createdAt, verified } = account;
@ -28,11 +30,17 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withDate = fal
) : null; ) : null;
const displayName = ( const displayName = (
<span className='display-name__name'> <HStack space={1} alignItems='center' grow>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> <Text
size='sm'
weight='semibold'
truncate
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
{verified && <VerificationBadge />} {verified && <VerificationBadge />}
{withDate && joinedAt} {withDate && joinedAt}
</span> </HStack>
); );
const suffix = (<span className='display-name__account'>@{getAcct(account, displayFqn)}</span>); 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> <HoverRefWrapper accountId={account.get('id')} inline>
{displayName} {displayName}
</HoverRefWrapper> </HoverRefWrapper>
{suffix} {withSuffix && suffix}
{children} {children}
</span> </span>
); );

View File

@ -6,7 +6,7 @@ import { spring } from 'react-motion';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import { withRouter, RouteComponentProps } from 'react-router-dom'; 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 SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import Motion from 'soapbox/features/ui/util/optional-motion'; 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} data-method={isLogout ? 'delete' : undefined}
title={text} 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> <span className='truncate'>{text}</span>
@ -259,6 +259,7 @@ export interface IDropdown extends RouteComponentProps {
text?: string, text?: string,
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>, onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
children?: JSX.Element, children?: JSX.Element,
dropdownMenuStyle?: React.CSSProperties,
} }
interface IDropdownState { interface IDropdownState {
@ -369,7 +370,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
} }
render() { 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; const open = this.state.id === openDropdownId;
return ( return (
@ -403,7 +404,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
)} )}
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> <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> </Overlay>
</> </>
); );

View File

@ -5,7 +5,7 @@ import { useDispatch } from 'react-redux';
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts'; import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
import { openModal } from 'soapbox/actions/modals'; 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 { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile'; import { isUserTouching } from 'soapbox/is-mobile';
import { getReactForStatus } from 'soapbox/utils/emoji-reacts'; 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 { getSoapboxConfig } from 'soapbox/actions/soapbox';
import * as BuildConfig from 'soapbox/build-config'; 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 { captureException } from 'soapbox/monitoring';
import KVStore from 'soapbox/storage/kv-store'; import KVStore from 'soapbox/storage/kv-store';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
@ -179,7 +179,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
</main> </main>
<footer className='flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'> <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') && ( {links.get('status') && (
<> <>
<a href={links.get('status')} className='text-sm font-medium text-gray-700 dark:text-gray-600 hover:underline'> <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> </a>
</> </>
)} )}
</nav> </HStack>
</footer> </footer>
</div> </div>
); );

View File

@ -31,7 +31,7 @@ const GdprBanner: React.FC = () => {
return ( return (
<Banner theme='opaque' className={classNames('transition-transform', { 'translate-y-full': slideout })}> <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}> <Stack space={2}>
<Text size='xl' weight='bold'> <Text size='xl' weight='bold'>
<FormattedMessage id='gdpr.title' defaultMessage='{siteTitle} uses cookies' values={{ siteTitle }} /> <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 { Helmet as ReactHelmet } from 'react-helmet';
import { useAppSelector, useSettings } from 'soapbox/hooks'; import { useAppSelector, useSettings } from 'soapbox/hooks';

View File

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

View File

@ -1,77 +1,82 @@
.status-content p { [data-markup] p {
@apply mb-4 whitespace-pre-wrap; @apply mb-4 whitespace-pre-wrap;
} }
.status-content p:last-child { [data-markup] p:last-child {
@apply mb-0; @apply mb-0;
} }
.status-content a { [data-markup] a {
@apply text-primary-600 dark:text-accent-blue hover:underline; @apply text-primary-600 dark:text-accent-blue hover:underline;
} }
.status-content strong { [data-markup] strong {
@apply font-bold; @apply font-bold;
} }
.status-content em { [data-markup] em {
@apply italic; @apply italic;
} }
.status-content ul, [data-markup] ul,
.status-content ol { [data-markup] ol {
@apply pl-10 mb-4; @apply pl-10 mb-4;
} }
.status-content ul { [data-markup] ul {
@apply list-disc list-outside; @apply list-disc list-outside;
} }
.status-content ol { [data-markup] ol {
@apply list-decimal list-outside; @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; @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; @apply cursor-text font-mono;
} }
.status-content p > code, [data-markup] p > code,
.status-content pre { [data-markup] pre {
@apply bg-gray-100 dark:bg-primary-800; @apply bg-gray-100 dark:bg-primary-800;
} }
/* Inline code */ /* Inline code */
.status-content p > code { [data-markup] p > code {
@apply py-0.5 px-1 rounded-sm; @apply py-0.5 px-1 rounded-sm;
} }
/* Code block */ /* Code block */
.status-content pre { [data-markup] pre {
@apply py-2 px-3 mb-4 leading-6 overflow-x-auto rounded-md break-all; @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; @apply mb-0;
} }
/* Emojis */
[data-markup] img.emojione {
@apply w-5 h-5;
}
/* Markdown images */ /* 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; @apply w-full h-72 object-contain rounded-lg overflow-hidden my-4 block;
} }
/* User setting to underline links */ /* User setting to underline links */
body.underline-links .status-content a { body.underline-links [data-markup] a {
@apply underline; @apply underline;
} }
.status-content .big-emoji img.emojione { [data-markup].big-emoji img.emojione {
@apply inline w-9 h-9 p-1; @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; @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> </div>
); );
} else if (attachment.type === 'image') { } else if (attachment.type === 'image') {
const letterboxed = shouldLetterbox(attachment); const letterboxed = total === 1 && shouldLetterbox(attachment);
thumbnail = ( thumbnail = (
<a <a
className={classNames('media-gallery__item-thumbnail', { letterboxed })} className='media-gallery__item-thumbnail'
href={attachment.url} href={attachment.url}
onClick={handleClick} onClick={handleClick}
target='_blank' 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> </a>
); );
} else if (attachment.type === 'gifv') { } else if (attachment.type === 'gifv') {

View File

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

View File

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

View File

@ -10,7 +10,7 @@ interface ISidebarNavigationLink {
/** URL to an SVG icon. */ /** URL to an SVG icon. */
icon: string, icon: string,
/** Link label. */ /** Link label. */
text: React.ReactElement, text: React.ReactNode,
/** Route to an internal page. */ /** Route to an internal page. */
to?: string, to?: string,
/** Callback when the link is clicked. */ /** Callback when the link is clicked. */
@ -37,7 +37,7 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
ref={ref} ref={ref}
onClick={handleClick} onClick={handleClick}
className={classNames({ 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, 'dark:text-gray-100 text-gray-900': isActive,
})} })}
> >

View File

@ -16,9 +16,6 @@ const messages = defineMessages({
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' }, lists: { id: 'column.lists', defaultMessage: 'Lists' },
developers: { id: 'navigation.developers', defaultMessage: 'Developers' }, 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. */ /** Desktop sidebar with links to different views in the app. */
@ -71,35 +68,6 @@ const SidebarNavigation = () => {
text: intl.formatMessage(messages.developers), 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; return menu;
@ -170,6 +138,33 @@ const SidebarNavigation = () => {
icon={require('@tabler/icons/settings.svg')} icon={require('@tabler/icons/settings.svg')}
text={<FormattedMessage id='tabs_bar.settings' defaultMessage='Settings' />} 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}> <DropdownMenu items={menu}>
<SidebarNavigationLink <SidebarNavigationLink
icon={require('@tabler/icons/dots-circle-horizontal.svg')} icon={require('@tabler/icons/dots-circle-horizontal.svg')}
count={dashboardCount}
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />} text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
/> />
</DropdownMenu> </DropdownMenu>

View File

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

View File

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

View File

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

View File

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

View File

@ -66,7 +66,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
if (to.size > 2) { if (to.size > 2) {
accounts.push( 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 }} /> <FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />
</span>, </span>,
); );

View File

@ -110,6 +110,11 @@ const Status: React.FC<IStatus> = (props) => {
const handleClick = (e?: React.MouseEvent): void => { const handleClick = (e?: React.MouseEvent): void => {
e?.stopPropagation(); 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 (!e || !(e.ctrlKey || e.metaKey)) {
if (onClick) { if (onClick) {
onClick(); onClick();

View File

@ -12,10 +12,14 @@ interface IStillImage {
src: string, src: string,
/** Extra CSS styles on the outer <div> element. */ /** Extra CSS styles on the outer <div> element. */
style?: React.CSSProperties, 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. */ /** 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 settings = useSettings();
const autoPlayGif = settings.get('autoPlayGif'); 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 ( return (
<div data-testid='still-image-container' className={classNames(className, 'still-image', { 'still-image--play-on-hover': hoverToPlay })} style={style}> <div
<img src={src} alt={alt} ref={img} onLoad={handleImageLoad} /> data-testid='still-image-container'
{hoverToPlay && <canvas ref={canvas} />} 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> </div>
); );
}; };

View File

@ -50,7 +50,7 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
} }
return ( 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' /> <FormattedMessage id='status.translate' defaultMessage='Translate' />
</button> </button>
); );

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; 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'; import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
const sizes = { const sizes = {
@ -62,7 +62,7 @@ const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }):
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick }; const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
return ( 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' /> <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> <span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
</Comp> </Comp>
@ -70,11 +70,11 @@ const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }):
}; };
return ( return (
<div className='mb-4 flex flex-row items-center'> <HStack alignItems='center' space={2} className='mb-4'>
{renderBackButton()} {renderBackButton()}
{children} {children}
</div> </HStack>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -21,13 +21,15 @@ const spaces = {
1: 'space-x-1', 1: 'space-x-1',
1.5: 'space-x-1.5', 1.5: 'space-x-1.5',
2: 'space-x-2', 2: 'space-x-2',
2.5: 'space-x-2.5',
3: 'space-x-3', 3: 'space-x-3',
4: 'space-x-4', 4: 'space-x-4',
5: 'space-x-5',
6: 'space-x-6', 6: 'space-x-6',
8: 'space-x-8', 8: 'space-x-8',
}; };
interface IHStack { interface IHStack extends Pick<React.HTMLAttributes<HTMLDivElement>, 'onClick'> {
/** Vertical alignment of children. */ /** Vertical alignment of children. */
alignItems?: keyof typeof alignItemsOptions alignItems?: keyof typeof alignItemsOptions
/** Extra class names on the <div> element. */ /** Extra class names on the <div> element. */
@ -58,7 +60,7 @@ const HStack = forwardRef<HTMLDivElement, IHStack>((props, ref) => {
<Elem <Elem
{...filteredProps} {...filteredProps}
ref={ref} ref={ref}
className={classNames('flex', { className={classNames('flex rtl:space-x-reverse', {
// @ts-ignore // @ts-ignore
[alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined', [alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined',
// @ts-ignore // @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 { render, screen } from '../../../../jest/test-helpers';
import SvgIcon from '../svg-icon'; 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 Select } from './select/select';
export { default as Spinner } from './spinner/spinner'; export { default as Spinner } from './spinner/spinner';
export { default as Stack } from './stack/stack'; export { default as Stack } from './stack/stack';
export { default as Streamfield } from './streamfield/streamfield';
export { default as Tabs } from './tabs/tabs'; export { default as Tabs } from './tabs/tabs';
export { default as TagInput } from './tag-input/tag-input'; export { default as TagInput } from './tag-input/tag-input';
export { default as Text } from './text/text'; 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-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', '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', '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, 'text-red-600 border-red-600': hasError,
'pl-8': typeof icon !== 'undefined', 'pl-8': typeof icon !== 'undefined',
'pl-16': typeof prepend !== 'undefined', 'pl-16': typeof prepend !== 'undefined',
}, className)} }, className)}
/> />
{/* eslint-disable-next-line no-nested-ternary */}
{append ? ( {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} {append}
</div> </div>
) : null} ) : null}
@ -111,7 +110,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
intl.formatMessage(messages.showPassword) 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 <button
type='button' type='button'
onClick={togglePassword} onClick={togglePassword}

View File

@ -1,9 +1,10 @@
import classNames from 'clsx'; import classNames from 'clsx';
import * as React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import Button from '../button/button'; import Button from '../button/button';
import IconButton from '../icon-button/icon-button'; import IconButton from '../icon-button/icon-button';
import Stack from '../stack/stack';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -81,7 +82,7 @@ const Modal: React.FC<IModal> = ({
}, [skipFocus, buttonRef]); }, [skipFocus, buttonRef]);
return ( 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='sm:flex sm:items-start w-full justify-between'>
<div className='w-full'> <div className='w-full'>
{title && ( {title && (
@ -126,7 +127,7 @@ const Modal: React.FC<IModal> = ({
)} )}
</div> </div>
<div className='flex flex-row space-x-2'> <Stack space={2}>
{secondaryAction && ( {secondaryAction && (
<Button <Button
theme='secondary' theme='secondary'
@ -145,7 +146,7 @@ const Modal: React.FC<IModal> = ({
> >
{confirmationText} {confirmationText}
</Button> </Button>
</div> </Stack>
</div> </div>
)} )}
</div> </div>

View File

@ -1,6 +1,8 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import HStack from '../hstack/hstack';
interface IRadioButton { interface IRadioButton {
value: string value: string
checked?: boolean checked?: boolean
@ -16,7 +18,7 @@ const RadioButton: React.FC<IRadioButton> = ({ name, value, checked, onChange, l
const formFieldId: string = useMemo(() => `radio-${uuidv4()}`, []); const formFieldId: string = useMemo(() => `radio-${uuidv4()}`, []);
return ( return (
<div className='flex items-center'> <HStack alignItems='center' space={3}>
<input <input
type='radio' type='radio'
name={name} 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' 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}
</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> { interface ISelect extends React.SelectHTMLAttributes<HTMLSelectElement> {
children: Iterable<React.ReactNode>, children: Iterable<React.ReactNode>,

View File

@ -6,7 +6,7 @@ import {
useTabsContext, useTabsContext,
} from '@reach/tabs'; } from '@reach/tabs';
import classNames from 'clsx'; import classNames from 'clsx';
import * as React from 'react'; import React from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import Counter from '../counter/counter'; 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 Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' | 'div'
type Directions = 'ltr' | 'rtl' 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. */ /** How to align the text. */
align?: keyof typeof alignments, align?: keyof typeof alignments,
/** Extra class names for the outer element. */ /** 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. */ /** UI-friendly text container with dark mode support. */
const Text: React.FC<IText> = React.forwardRef( const Text = React.forwardRef<any, IText>(
(props: IText, ref: React.LegacyRef<any>) => { (props, ref) => {
const { const {
align, align,
className, className,

View File

@ -1,7 +1,7 @@
import classNames from 'clsx'; import classNames from 'clsx';
import React from 'react'; 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. */ /** Put the cursor into the input on mount. */
autoFocus?: boolean, autoFocus?: boolean,
/** The initial text in the input. */ /** The initial text in the input. */

View File

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

View File

@ -2,7 +2,7 @@ import classNames from 'clsx';
import React from 'react'; import React from 'react';
import { useIntl, defineMessages } from 'react-intl'; 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'; import { useSoapboxConfig } from 'soapbox/hooks';
const messages = defineMessages({ const messages = defineMessages({

View File

@ -49,6 +49,8 @@ import ErrorBoundary from '../components/error-boundary';
import UI from '../features/ui'; import UI from '../features/ui';
import { store } from '../store'; import { store } from '../store';
const RTL_LOCALES = ['ar', 'ckb', 'fa', 'he'];
// Configure global functions for developers // Configure global functions for developers
createGlobals(store); createGlobals(store);
@ -276,7 +278,7 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
<> <>
<Helmet> <Helmet>
<html lang={locale} className={classNames('h-full', { dark: darkMode })} /> <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>} {themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
{darkMode && <style type='text/css'>{':root { color-scheme: dark; }'}</style>} {darkMode && <style type='text/css'>{':root { color-scheme: dark; }'}</style>}
<meta name='theme-color' content={soapboxConfig.brandColor} /> <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} src={attachment.preview_url}
alt={attachment.description} alt={attachment.description}
style={{ objectPosition: `${x}% ${y}%` }} style={{ objectPosition: `${x}% ${y}%` }}
className='w-full h-full rounded-lg overflow-hidden'
/> />
); );
} else if (['gifv', 'video'].indexOf(attachment.type) !== -1) { } 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 snackbar from 'soapbox/actions/snackbar';
import Badge from 'soapbox/components/badge'; import Badge from 'soapbox/components/badge';
import StillImage from 'soapbox/components/still-image'; 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 SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import MovedNote from 'soapbox/features/account-timeline/components/moved-note'; import MovedNote from 'soapbox/features/account-timeline/components/moved-note';
import ActionButton from 'soapbox/features/ui/components/action-button'; 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' }, 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}' }, userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' },
profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' }, profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' },
header: { id: 'account.header.alt', defaultMessage: 'Profile header' },
}); });
interface IHeader { interface IHeader {
@ -89,13 +90,13 @@ const Header: React.FC<IHeader> = ({ account }) => {
</div> </div>
<div className='px-4 sm:px-6'> <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='flex relative'>
<div <div
className='h-24 w-24 bg-gray-400 rounded-full ring-4 ring-white dark:ring-gray-800' className='h-24 w-24 bg-gray-400 rounded-full ring-4 ring-white dark:ring-gray-800'
/> />
</div> </div>
</div> </HStack>
</div> </div>
</div> </div>
); );
@ -551,13 +552,12 @@ const Header: React.FC<IHeader> = ({ account }) => {
)} )}
<div> <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 && ( {account.header && (
<a href={account.header} onClick={handleHeaderClick} target='_blank'> <a href={account.header} onClick={handleHeaderClick} target='_blank'>
<StillImage <StillImage
src={account.header} src={account.header}
alt='Profile Header' alt={intl.formatMessage(messages.header)}
className='absolute inset-0 object-cover md:rounded-t-xl'
/> />
</a> </a>
)} )}
@ -571,19 +571,19 @@ const Header: React.FC<IHeader> = ({ account }) => {
</div> </div>
<div className='px-4 sm:px-6'> <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'> <div className='flex'>
<a href={account.avatar} onClick={handleAvatarClick} target='_blank'> <a href={account.avatar} onClick={handleAvatarClick} target='_blank'>
<Avatar <Avatar
src={account.avatar} src={account.avatar}
size={96} 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> </a>
</div> </div>
<div className='mt-6 flex justify-end w-full sm:pb-1'> <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} /> <SubscriptionButton account={account} />
{ownAccount && ( {ownAccount && (
@ -607,13 +607,13 @@ const Header: React.FC<IHeader> = ({ account }) => {
return ( return (
<Comp key={idx} {...itemProps} className='group'> <Comp key={idx} {...itemProps} className='group'>
<div className='flex items-center'> <HStack space={3} alignItems='center'>
{menuItem.icon && ( {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 className='truncate'>{menuItem.text}</div>
</div> </HStack>
</Comp> </Comp>
); );
} }
@ -626,9 +626,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
{/* {renderMessageButton()} */} {/* {renderMessageButton()} */}
<ActionButton account={account} /> <ActionButton account={account} />
</HStack>
</div> </div>
</div> </HStack>
</div>
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

@ -53,7 +53,7 @@ const Search: React.FC = () => {
placeholder={intl.formatMessage(messages.search)} 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 })} /> <Icon src={require('@tabler/icons/backspace.svg')} aria-label={intl.formatMessage(messages.search)} className={classNames('svg-icon--backspace', { active: hasValue })} />
</div> </div>
</label> </label>

View File

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

View File

@ -26,23 +26,26 @@ const LoginPage = () => {
const [mfaToken, setMfaToken] = useState(token || ''); const [mfaToken, setMfaToken] = useState(token || '');
const [shouldRedirect, setShouldRedirect] = useState(false); const [shouldRedirect, setShouldRedirect] = useState(false);
const getFormData = (form: HTMLFormElement) => { const getFormData = (form: HTMLFormElement) =>
return Object.fromEntries( Object.fromEntries(
Array.from(form).map((i: any) => [i.name, i.value]), Array.from(form).map((i: any) => [i.name, i.value]),
); );
};
const handleSubmit: React.FormEventHandler = (event) => { const handleSubmit: React.FormEventHandler = (event) => {
const { username, password } = getFormData(event.target as HTMLFormElement); const { username, password } = getFormData(event.target as HTMLFormElement);
dispatch(logIn(username, password)).then(({ access_token }) => { dispatch(logIn(username, password))
return dispatch(verifyCredentials(access_token as string)) .then(({ access_token }) => dispatch(verifyCredentials(access_token as string)))
// Refetch the instance for authenticated fetch // Refetch the instance for authenticated fetch
.then(() => dispatch(fetchInstance() as any)); .then(async (account) => {
}).then((account: { id: string }) => { await dispatch(fetchInstance());
return account;
})
.then((account: { id: string }) => {
dispatch(closeModal()); dispatch(closeModal());
setShouldRedirect(true);
if (typeof me === 'string') { if (typeof me === 'string') {
dispatch(switchAccount(account.id)); dispatch(switchAccount(account.id));
} else {
setShouldRedirect(true);
} }
}).catch((error: AxiosError) => { }).catch((error: AxiosError) => {
const data: any = error.response?.data; 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 { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
@ -11,6 +11,7 @@ const token = new URLSearchParams(window.location.search).get('reset_password_to
const messages = defineMessages({ const messages = defineMessages({
resetPasswordFail: { id: 'reset_password.fail', defaultMessage: 'Expired token, please try again.' }, resetPasswordFail: { id: 'reset_password.fail', defaultMessage: 'Expired token, please try again.' },
passwordPlaceholder: { id: 'reset_password.password.placeholder', defaultMessage: 'Placeholder' },
}); });
const Statuses = { const Statuses = {
@ -66,11 +67,11 @@ const PasswordResetConfirm = () => {
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'> <div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<FormGroup labelText='Password' errors={renderErrors()}> <FormGroup labelText={<FormattedMessage id='reset_password.password.label' defaultMessage='Password' />} errors={renderErrors()}>
<Input <Input
type='password' type='password'
name='password' name='password'
placeholder='Password' placeholder={intl.formatMessage(messages.passwordPlaceholder)}
onChange={onChange} onChange={onChange}
required required
/> />

View File

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

View File

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

View File

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

View File

@ -1,10 +1,9 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display-name'; import DisplayName from 'soapbox/components/display-name';
import Icon from 'soapbox/components/icon'; 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 emojify from 'soapbox/features/emoji/emoji';
import { useAppSelector } from 'soapbox/hooks'; import { useAppSelector } from 'soapbox/hooks';
import { makeGetChat } from 'soapbox/selectors'; import { makeGetChat } from 'soapbox/selectors';
@ -34,37 +33,38 @@ const Chat: React.FC<IChat> = ({ chatId, onClick }) => {
return ( return (
<div className='account'> <div className='account'>
<button className='floating-link' onClick={() => onClick(chat)} /> <button className='floating-link' onClick={() => onClick(chat)} />
<div className='account__wrapper'> <HStack key={account.id} space={3} className='relative overflow-hidden'>
<div key={account.id} className='account__display-name'> <Avatar className='flex-none' src={account.avatar} size={36} />
<div className='account__avatar-wrapper'> <Stack className='overflow-hidden flex-1'>
<Avatar account={account} size={36} /> <DisplayName account={account} withSuffix={false} />
</div> <HStack space={1} justifyContent='between'>
<DisplayName account={account} /> {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 && ( {attachment && (
<Icon <Icon
className='chat__attachment-icon' className='chat__attachment-icon'
src={image ? require('@tabler/icons/photo.svg') : require('@tabler/icons/paperclip.svg')} src={image ? require('@tabler/icons/photo.svg') : require('@tabler/icons/paperclip.svg')}
/> />
)} )}
{content ? ( </HStack>
<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>
)}
{unreadCount > 0 && ( {unreadCount > 0 && (
<div className='absolute top-1 right-0'> <div className='absolute top-1 right-0'>
<Counter count={unreadCount} /> <Counter count={unreadCount} />
</div> </div>
)} )}
</div> </Stack>
</div> </HStack>
</div> </div>
); );
}; };

View File

@ -16,7 +16,7 @@ import {
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea'; import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
import Icon from 'soapbox/components/icon'; 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 { useAppDispatch, useAppSelector, useCompose, useFeatures, usePrevious } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is-mobile'; import { isMobile } from 'soapbox/is-mobile';
@ -221,7 +221,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
}, [focusDate]); }, [focusDate]);
const renderButtons = useCallback(() => ( const renderButtons = useCallback(() => (
<div className='flex items-center space-x-2'> <HStack alignItems='center' space={2}>
{features.media && <UploadButtonContainer composeId={id} />} {features.media && <UploadButtonContainer composeId={id} />}
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} /> <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
{features.polls && <PollButton composeId={id} />} {features.polls && <PollButton composeId={id} />}
@ -229,7 +229,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
{features.scheduledStatuses && <ScheduleButton composeId={id} />} {features.scheduledStatuses && <ScheduleButton composeId={id} />}
{features.spoilers && <SpoilerButton composeId={id} />} {features.spoilers && <SpoilerButton composeId={id} />}
{features.richText && <MarkdownButton composeId={id} />} {features.richText && <MarkdownButton composeId={id} />}
</div> </HStack>
), [features, id]); ), [features, id]);
const condensed = shouldCondense && !composeFocused && isEmpty() && !isUploading; const condensed = shouldCondense && !composeFocused && isEmpty() && !isUploading;
@ -335,16 +335,18 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
> >
{renderButtons()} {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 && ( {maxTootChars && (
<div className='flex items-center space-x-1'> <HStack space={1} alignItems='center'>
<TextCharacterCounter max={maxTootChars} text={text} /> <TextCharacterCounter max={maxTootChars} text={text} />
<VisualCharacterCounter max={maxTootChars} text={text} /> <VisualCharacterCounter max={maxTootChars} text={text} />
</div> </HStack>
)} )}
<Button type='submit' theme='primary' text={publishText} disabled={disabledButton} /> <Button type='submit' theme='primary' text={publishText} disabled={disabledButton} />
</div> </HStack>
{/* <HStack alignItems='center' space={4}>
</HStack> */}
</div> </div>
</Stack> </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 React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { buildCustomEmojis } from '../../../emoji/emoji'; import { buildCustomEmojis, categoriesFromEmojis } from '../../../emoji/emoji';
import { EmojiPicker } from './emoji-picker-dropdown'; import { EmojiPicker } from './emoji-picker-dropdown';
import ModifierPicker from './modifier-picker'; 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 backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
const listenerOptions = supportsPassiveEvents ? { passive: true } : false; const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = [
'recent',
'custom',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' }, emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
@ -71,6 +58,20 @@ const EmojiPickerMenu: React.FC<IEmojiPickerMenu> = ({
const [modifierOpen, setModifierOpen] = useState(false); 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 => { const handleDocumentClick = useCallback(e => {
if (node.current && !node.current.contains(e.target)) { if (node.current && !node.current.contains(e.target)) {
onClose(); onClose();

View File

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

View File

@ -149,7 +149,7 @@ const Search = (props: ISearch) => {
<div <div
role='button' role='button'
tabIndex={0} 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} onClick={handleClear}
> >
<SvgIcon <SvgIcon

View File

@ -33,7 +33,7 @@ const CryptoAddress: React.FC<ICryptoAddress> = (props): JSX.Element => {
<Stack> <Stack>
<HStack alignItems='center' className='mb-1'> <HStack alignItems='center' className='mb-1'>
<CryptoIcon <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} ticker={ticker}
title={title} title={title}
/> />
@ -41,12 +41,12 @@ const CryptoAddress: React.FC<ICryptoAddress> = (props): JSX.Element => {
<Text weight='bold'>{title || ticker.toUpperCase()}</Text> <Text weight='bold'>{title || ticker.toUpperCase()}</Text>
<HStack alignItems='center' className='ml-auto'> <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} /> <Icon src={require('@tabler/icons/qrcode.svg')} size={20} />
</a> </a>
{explorerUrl && ( {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} /> <Icon src={require('@tabler/icons/external-link.svg')} size={20} />
</a> </a>
)} )}

View File

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

View File

@ -29,6 +29,7 @@ const isJSONValid = (text: any): boolean => {
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.settings_store', defaultMessage: 'Settings store' }, 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.' }, 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> </Form>
<CardHeader> <CardHeader>
<CardTitle title='Advanced settings' /> <CardTitle title={intl.formatMessage(messages.advanced)} />
</CardHeader> </CardHeader>
<List> <List>

View File

@ -5,14 +5,13 @@ import { directComposeById } from 'soapbox/actions/compose';
import { connectDirectStream } from 'soapbox/actions/streaming'; import { connectDirectStream } from 'soapbox/actions/streaming';
import { expandDirectTimeline } from 'soapbox/actions/timelines'; import { expandDirectTimeline } from 'soapbox/actions/timelines';
import AccountSearch from 'soapbox/components/account-search'; import AccountSearch from 'soapbox/components/account-search';
import ColumnHeader from 'soapbox/components/column-header';
import { Column } from 'soapbox/components/ui'; import { Column } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import Timeline from '../ui/components/timeline'; import Timeline from '../ui/components/timeline';
const messages = defineMessages({ 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…' }, searchPlaceholder: { id: 'direct.search_placeholder', defaultMessage: 'Send a message to…' },
}); });
@ -20,8 +19,6 @@ const DirectTimeline = () => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const hasUnread = useAppSelector((state) => (state.timelines.get('direct')?.unread || 0) > 0);
useEffect(() => { useEffect(() => {
dispatch(expandDirectTimeline()); dispatch(expandDirectTimeline());
const disconnect = dispatch(connectDirectStream()); const disconnect = dispatch(connectDirectStream());
@ -40,13 +37,7 @@ const DirectTimeline = () => {
}; };
return ( return (
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}> <Column label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='envelope'
active={hasUnread}
title={intl.formatMessage(messages.title)}
/>
<AccountSearch <AccountSearch
placeholder={intl.formatMessage(messages.searchPlaceholder)} placeholder={intl.formatMessage(messages.searchPlaceholder)}
onSelected={handleSuggestion} onSelected={handleSuggestion}
@ -57,7 +48,7 @@ const DirectTimeline = () => {
timelineId='direct' timelineId='direct'
onLoadMore={handleLoadMore} 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." />} 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> </Column>
); );

View File

@ -1,4 +1,4 @@
import * as React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { changeEmail } from 'soapbox/actions/security'; 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 { defineMessages, useIntl } from 'react-intl';
import { changePassword } from 'soapbox/actions/security'; import { changePassword } from 'soapbox/actions/security';

View File

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

View File

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

View File

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

View File

@ -88,5 +88,10 @@ describe('emoji', () => {
expect(emojify('💂‍♀️💂‍♂️')) 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">'); .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 trie = new Trie(Object.keys(unicodeMapping));
const domParser = new DOMParser();
const emojifyTextNode = (node, customEmojis, autoPlayGif = false) => { const emojifyTextNode = (node, customEmojis, autoPlayGif = false) => {
let str = node.textContent; 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) { if (i === str.length) {
break; break;
} else if (str[i] === ':') { } 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 you want additional emoji handler, add statements below which set replacement and return true.
if (shortname in customEmojis) { if (shortname in customEmojis) {
const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url; 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 true;
} }
return false; return false;
@ -47,8 +52,12 @@ const emojifyTextNode = (node, customEmojis, autoPlayGif = false) => {
} else { // matched to unicode emoji } else { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match]; const { filename, shortCode } = unicodeMapping[match];
const title = shortCode ? `:${shortCode}:` : ''; const title = shortCode ? `:${shortCode}:` : '';
const src = joinPublicPath(`packs/emoji/${filename}.svg`); replacement = document.createElement('img');
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${src}" />`; 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; rend = i + match.length;
// If the matched character was followed by VS15 (for selecting text presentation), skip it. // If the matched character was followed by VS15 (for selecting text presentation), skip it.
if (str.codePointAt(rend) === 65038) { if (str.codePointAt(rend) === 65038) {
@ -58,7 +67,7 @@ const emojifyTextNode = (node, customEmojis, autoPlayGif = false) => {
fragment.append(document.createTextNode(str.slice(0, i))); fragment.append(document.createTextNode(str.slice(0, i)));
if (replacement) { if (replacement) {
fragment.append(domParser.parseFromString(replacement, 'text/html').documentElement.getElementsByTagName('img')[0]); fragment.append(replacement);
} }
node.textContent = str.slice(0, i); node.textContent = str.slice(0, i);
str = str.slice(rend); str = str.slice(rend);

View File

@ -4,7 +4,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon'; 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 { useAppSelector } from 'soapbox/hooks';
import type { Map as ImmutableMap } from 'immutable'; 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); .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 { interface IInstanceRestrictions {
remoteInstance: ImmutableMap<string, any>, remoteInstance: ImmutableMap<string, any>,
} }
@ -40,57 +57,52 @@ const InstanceRestrictions: React.FC<IInstanceRestrictions> = ({ remoteInstance
if (followers_only) { if (followers_only) {
items.push(( items.push((
<Text key='followers_only' className='flex items-center gap-2' theme='muted'> <Restriction key='followersOnly' icon={require('@tabler/icons/lock.svg')}>
<Icon src={require('@tabler/icons/lock.svg')} />
<FormattedMessage <FormattedMessage
id='federation_restriction.followers_only' id='federation_restriction.followers_only'
defaultMessage='Hidden except to followers' defaultMessage='Hidden except to followers'
/> />
</Text> </Restriction>
)); ));
} else if (federated_timeline_removal) { } else if (federated_timeline_removal) {
items.push(( items.push((
<Text key='federated_timeline_removal' className='flex items-center gap-2' theme='muted'> <Restriction key='federatedTimelineRemoval' icon={require('@tabler/icons/lock-open.svg')}>
<Icon src={require('@tabler/icons/lock-open.svg')} />
<FormattedMessage <FormattedMessage
id='federation_restriction.federated_timeline_removal' id='federation_restriction.federated_timeline_removal'
defaultMessage='Fediverse timeline removal' defaultMessage='Fediverse timeline removal'
/> />
</Text> </Restriction>
)); ));
} }
if (fullMediaRemoval) { if (fullMediaRemoval) {
items.push(( items.push((
<Text key='full_media_removal' className='flex items-center gap-2' theme='muted'> <Restriction key='fullMediaRemoval' icon={require('@tabler/icons/photo-off.svg')}>
<Icon src={require('@tabler/icons/photo-off.svg')} />
<FormattedMessage <FormattedMessage
id='federation_restriction.full_media_removal' id='federation_restriction.full_media_removal'
defaultMessage='Full media removal' defaultMessage='Full media removal'
/> />
</Text> </Restriction>
)); ));
} else if (partialMediaRemoval) { } else if (partialMediaRemoval) {
items.push(( items.push((
<Text key='partial_media_removal' className='flex items-center gap-2' theme='muted'> <Restriction key='partialMediaRemoval' icon={require('@tabler/icons/photo-off.svg')}>
<Icon src={require('@tabler/icons/photo-off.svg')} />
<FormattedMessage <FormattedMessage
id='federation_restriction.partial_media_removal' id='federation_restriction.partial_media_removal'
defaultMessage='Partial media removal' defaultMessage='Partial media removal'
/> />
</Text> </Restriction>
)); ));
} }
if (!fullMediaRemoval && media_nsfw) { if (!fullMediaRemoval && media_nsfw) {
items.push(( items.push((
<Text key='media_nsfw' className='flex items-center gap-2' theme='muted'> <Restriction key='mediaNsfw' icon={require('@tabler/icons/eye-off.svg')}>
<Icon src={require('@tabler/icons/eye-off.svg')} />
<FormattedMessage <FormattedMessage
id='federation_restriction.media_nsfw' id='federation_restriction.media_nsfw'
defaultMessage='Attachments marked NSFW' defaultMessage='Attachments marked NSFW'
/> />
</Text> </Restriction>
)); ));
} }
@ -105,46 +117,45 @@ const InstanceRestrictions: React.FC<IInstanceRestrictions> = ({ remoteInstance
if (remoteInstance.getIn(['federation', 'reject']) === true) { if (remoteInstance.getIn(['federation', 'reject']) === true) {
return ( return (
<Text className='flex items-center gap-2' theme='muted'> <Restriction icon={require('@tabler/icons/shield-x.svg')}>
<Icon src={require('@tabler/icons/x.svg')} />
<FormattedMessage <FormattedMessage
id='remote_instance.federation_panel.restricted_message' id='remote_instance.federation_panel.restricted_message'
defaultMessage='{siteTitle} blocks all activities from {host}.' defaultMessage='{siteTitle} blocks all activities from {host}.'
values={{ host, siteTitle }} values={{ host, siteTitle }}
/> />
</Text> </Restriction>
); );
} else if (hasRestrictions(remoteInstance)) { } else if (hasRestrictions(remoteInstance)) {
return [ return (
( <>
<Text theme='muted'> <Restriction icon={require('@tabler/icons/shield-lock.svg')}>
<FormattedMessage <FormattedMessage
id='remote_instance.federation_panel.some_restrictions_message' id='remote_instance.federation_panel.some_restrictions_message'
defaultMessage='{siteTitle} has placed some restrictions on {host}.' defaultMessage='{siteTitle} has placed some restrictions on {host}.'
values={{ host, siteTitle }} values={{ host, siteTitle }}
/> />
</Text> </Restriction>
),
renderRestrictions(), {renderRestrictions()}
]; </>
);
} else { } else {
return ( return (
<Text className='flex items-center gap-2' theme='muted'> <Restriction icon={require('@tabler/icons/shield-check.svg')}>
<Icon src={require('@tabler/icons/check.svg')} />
<FormattedMessage <FormattedMessage
id='remote_instance.federation_panel.no_restrictions_message' id='remote_instance.federation_panel.no_restrictions_message'
defaultMessage='{siteTitle} has placed no restrictions on {host}.' defaultMessage='{siteTitle} has placed no restrictions on {host}.'
values={{ host, siteTitle }} values={{ host, siteTitle }}
/> />
</Text> </Restriction>
); );
} }
}; };
return ( 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()} {renderContent()}
</div> </Stack>
); );
}; };

View File

@ -35,7 +35,7 @@ const SuggestionItem = ({ accountId }: { accountId: string }) => {
<HStack alignItems='center' justifyContent='center' space={1}> <HStack alignItems='center' justifyContent='center' space={1}>
<Text <Text
weight='semibold' weight='semibold'
dangerouslySetInnerHTML={{ __html: account.display_name }} dangerouslySetInnerHTML={{ __html: account.display_name_html }}
truncate truncate
align='center' align='center'
size='sm' size='sm'
@ -78,7 +78,7 @@ const FeedSuggestions = () => {
</HStack> </HStack>
<CardBody> <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) => ( {suggestedProfiles.slice(0, 4).map((suggestedProfile) => (
<SuggestionItem key={suggestedProfile.account} accountId={suggestedProfile.account} /> <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 LandingPage from '..';
import { rememberInstance } from '../../../actions/instance'; 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 { FormattedMessage } from 'react-intl';
import { prepareRequest } from 'soapbox/actions/consumer-auth'; import { prepareRequest } from 'soapbox/actions/consumer-auth';
import Markup from 'soapbox/components/markup';
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui'; import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge'; import VerificationBadge from 'soapbox/components/verification-badge';
import RegistrationForm from 'soapbox/features/auth-login/components/registration-form'; import RegistrationForm from 'soapbox/features/auth-login/components/registration-form';
import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import { capitalize } from 'soapbox/utils/strings'; import { capitalize } from 'soapbox/utils/strings';
import './instance-description.css';
const LandingPage = () => { const LandingPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const features = useFeatures(); const features = useFeatures();
@ -107,19 +106,17 @@ const LandingPage = () => {
<main className='mt-16 sm:mt-24' data-testid='homepage'> <main className='mt-16 sm:mt-24' data-testid='homepage'>
<div className='mx-auto max-w-7xl'> <div className='mx-auto max-w-7xl'>
<div className='grid grid-cols-1 lg:grid-cols-12 gap-8 py-12'> <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'> <div className='w-full'>
<Stack space={3}> <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'> <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} {instance.title}
</h1> </h1>
<Text size='lg'> <Markup
<span size='lg'
className='instance-description'
dangerouslySetInnerHTML={{ __html: instance.short_description || instance.description }} dangerouslySetInnerHTML={{ __html: instance.short_description || instance.description }}
/> />
</Text>
</Stack> </Stack>
</div> </div>
</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 { setupListAdder, resetListAdder } from 'soapbox/actions/lists';
import { CardHeader, CardTitle, Modal } from 'soapbox/components/ui'; import { CardHeader, CardTitle, Modal } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import NewListForm from '../lists/components/new-list-form'; import NewListForm from '../lists/components/new-list-form';
import Account from './components/account';
import List from './components/list'; import List from './components/list';
import type { List as ImmutableList } from 'immutable'; 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' />} title={<FormattedMessage id='list_adder.header_title' defaultMessage='Add or Remove from Lists' />}
onClose={onClickClose} onClose={onClickClose}
> >
<Account accountId={accountId} /> <AccountContainer id={accountId} withRelationship={false} />
<br /> <br />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import * as React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ScrollableList from 'soapbox/components/scrollable-list'; 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'; import { Stack } from 'soapbox/components/ui';

View File

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

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