Archived
2
0
Fork 0

Gearheads: merge to Mastodon 4.2.0-beta1

This commit is contained in:
Ducky 2023-08-12 16:19:10 +01:00
commit 97f51e5301
2978 changed files with 78726 additions and 53942 deletions

View file

@ -0,0 +1,17 @@
import { useCallback, useState } from 'react';
export const useHovering = (animate?: boolean) => {
const [hovering, setHovering] = useState<boolean>(animate ?? false);
const handleMouseEnter = useCallback(() => {
if (animate) return;
setHovering(true);
}, [animate]);
const handleMouseLeave = useCallback(() => {
if (animate) return;
setHovering(false);
}, [animate]);
return { hovering, handleMouseEnter, handleMouseLeave };
};

View file

@ -0,0 +1,57 @@
<svg width="293" height="264" viewBox="0 0 293 264" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M34.1 204.9C28.1 210.7 22.3 209.9 15.9 204.3C13.4 202.1 11.3 205.2 15.3 209.2C19.3 213.2 28.8 217.5 37.3 210" fill="#E09C5C"/>
<path d="M34.1 204.9C28.1 210.7 22.3 209.9 15.9 204.3C13.4 202.1 11.3 205.2 15.3 209.2C19.3 213.2 28.8 217.5 37.3 210" stroke="#946F3A" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M197.2 1.90002H137.4C122.4 1.90002 110.1 14.2 110.1 29.2V42.1C110.1 48.4 112.3 54.2 115.9 58.9C109.2 72.6 94.5 75.8 94.5 75.8C113.4 78.1 119.1 72.3 125.2 66.6C128.9 68.4 133 69.5 137.4 69.5H197.2C212.2 69.5 224.5 57.2 224.5 42.2V29.3C224.6 14.2 212.3 1.90002 197.2 1.90002Z" fill="white" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M78.3 210.9C78.2 222.8 75.9 232 86.2 232.1C96.5 232.2 99.4 229.6 99.3 222C99.2 214.4 98.7 201 98.7 201" fill="#E09C5C"/>
<path d="M78.3 210.9C78.2 222.8 75.9 232 86.2 232.1C96.5 232.2 99.4 229.6 99.3 222C99.2 214.4 98.7 201 98.7 201" stroke="#946F3A" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M63.8 104.2C43.6 104.2 28.7 111.8 28.7 140.2C28.7 168.6 28.7 186.5 28.7 194.5C28.7 202.5 33.1 216.3 50.5 216.3C67.9 216.3 66.1 216.3 74.6 216.3C83.1 216.3 90.9 213.5 97.1 206.9C103.3 200.3 106.3 193.6 106.3 181.4C106.3 169.2 106.3 134.8 106.3 134.8C106.3 134.8 126.7 134.3 135.7 121.5C144.6 108.7 142.6 93.3001 141.4 88.9001C141.4 88.9001 146.5 88.4 145.4 84.5C144.3 80.6 138.1 81.2001 135.3 83.4001C135.3 83.4001 133.7 81.6 128.3 81.7C122.9 81.8 124.6 87.9001 129 88.4001C129 88.4001 131.4 102.2 124.4 107.1C117.4 112 103 113.7 94.6 109.8C86.1 106.1 83.3 104.2 63.8 104.2Z" fill="#FBC16C" stroke="#946F3A" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M50.5 215.2C30.4 215.2 29.9 196.7 29.9 194.6V171.5C42.9 171.6 48.7 173 48.7 183.9C48.7 197.1 50.9 203.7 62.6 203.7H90.4C91.1 203.7 91.7 203.7 92.3 203.7C92.9 203.7 93.4 203.7 94 203.7C95.7 203.7 97.4 203.6 99 203C98.2 204 97.3 205.1 96.3 206.2C90.7 212.2 83.4 215.2 74.7 215.2H50.5Z" fill="#E09C5C"/>
<path d="M56.8 110.5C47.2 107.6 42.1 105.6 45 97.2C47.9 88.8 62 90.6 69.7 94.5C69.7 94.5 73.7 92.1 76 91.2C78.3 90.3 82.6 89.9001 82.7 92.9001C82.7 92.9001 88.1 93.5001 87.1 97.8001C87.1 97.8001 93.5 101.5 79 108.5" fill="#FBC16C"/>
<path d="M56.8 110.5C47.2 107.6 42.1 105.6 45 97.2C47.9 88.8 62 90.6 69.7 94.5C69.7 94.5 73.7 92.1 76 91.2C78.3 90.3 82.6 89.9001 82.7 92.9001C82.7 92.9001 88.1 93.5001 87.1 97.8001C87.1 97.8001 93.5 101.5 79 108.5" stroke="#946F3A" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M40.5 119.9C40.5 113.5 36.7 109.6 28.7 109.6C20.7 109.6 13.9 117.1 15.7 128.3C17.5 139.5 11.4 148.2 18.5 153.2C25.6 158.2 32.9 155.1 35.2 151.3C35.2 151.3 42.8 153.3 42.2 141.4" fill="#FBC16C"/>
<path d="M40.5 119.9C40.5 113.5 36.7 109.6 28.7 109.6C20.7 109.6 13.9 117.1 15.7 128.3C17.5 139.5 11.4 148.2 18.5 153.2C25.6 158.2 32.9 155.1 35.2 151.3C35.2 151.3 42.8 153.3 42.2 141.4" stroke="#946F3A" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M69.3 134.2C63.6 134.2 60.5 138.6 61.8 144.4C63.1 150.1 66.8 154.4 73.2 154.6C79.6 154.7 85.7 152.5 87.6 143.2C89.5 133.9 86 133.8 83.2 133.8C80.4 133.8 69.3 134.2 69.3 134.2Z" fill="#544024" stroke="#3B3024" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M78.8 147.3C78.8 140.6 73.4 135.1 66.6 135.1C66 135.1 65.5001 135.2 64.9001 135.2C62.1001 136.8 60.8 140.2 61.7 144.3C63 150 66.7 154.3 73.1 154.5C74.2 154.5 75.3001 154.5 76.4001 154.3C78.0001 152.3 78.8 149.9 78.8 147.3Z" fill="#693131" stroke="#381916" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M83.1 140.9V133.7C80.1 133.7 69.3 134.1 69.3 134.1C64.8 134.1 61.9 136.9 61.5 140.9H83.1Z" fill="white" stroke="#7D7D65" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M48.2 149.1C54.2 154.9 62.5 154.9 66.3 151.7C70.1 148.5 72.1 140.3 69 139.5C65.9 138.7 66.6 144.2 63.8 145.8C61 147.4 57.8 147 54.8 145.1C51.9 143.2 48.9 141.9 47.4 143.7C46.1 145.7 46.8 147.7 48.2 149.1Z" fill="white" stroke="#7D7D65" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M251.4 179.1C245.7 185.3 237.4 185.7 233.4 182.7C229.4 179.7 227 171.7 230 170.7C233 169.7 232.7 175.2 235.5 176.7C238.3 178.2 241.6 177.6 244.4 175.6C247.2 173.6 250.1 172 251.7 173.9C253.3 175.8 252.8 177.7 251.4 179.1Z" stroke="black" stroke-width="0.5707" stroke-miterlimit="10"/>
<path d="M36.3 153.7L15.8 153.8C15.8 153.8 15.1 150.4 12 150.9C8.90001 151.4 8.19998 153.2 8.09998 154.3C8.09998 154.3 1.30002 154.7 1.20002 161.5C1.10002 168.3 6.1 171 13.3 170.6C20.5 170.2 37.7 170.6 37.7 170.6" fill="#FBC16C"/>
<path d="M36.3 153.7L15.8 153.8C15.8 153.8 15.1 150.4 12 150.9C8.90001 151.4 8.19998 153.2 8.09998 154.3C8.09998 154.3 1.30002 154.7 1.20002 161.5C1.10002 168.3 6.1 171 13.3 170.6C20.5 170.2 37.7 170.6 37.7 170.6" stroke="#946F3A" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M37.4 210.9C37.3 222.8 35 232 45.3 232.1C55.6 232.2 58.5 229.6 58.4 222C58.3 214.4 58.2 211.6 58.2 211.6" fill="#E09C5C"/>
<path d="M37.4 210.9C37.3 222.8 35 232 45.3 232.1C55.6 232.2 58.5 229.6 58.4 222C58.3 214.4 58.2 211.6 58.2 211.6" stroke="#946F3A" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M54.7 138.2C53.8 138.2 53.1 137.5 53.1 136.6V125.7C53.1 124.8 53.8 124.1 54.7 124.1C55.6 124.1 56.3 124.8 56.3 125.7V136.6C56.3 137.5 55.6 138.2 54.7 138.2Z" fill="#402F19"/>
<path d="M196.8 191.4C189.6 192.3 180.1 190.7 175.2 189.6C170.3 188.5 167.4 193.9 166.7 199.1C166 204.3 178.4 213.2 199.4 207.8" fill="#FBE6C6"/>
<path d="M196.8 191.4C189.6 192.3 180.1 190.7 175.2 189.6C170.3 188.5 167.4 193.9 166.7 199.1C166 204.3 178.4 213.2 199.4 207.8" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M265 188.6C278.1 192.7 287.2 200.3 287 208.9C286.8 217.5 277.4 223.1 270.5 224.4C263.6 225.7 260.7 218.3 261.6 213.3C262.5 208.3 265 206.7 265 206.7" fill="#FBE6C6"/>
<path d="M265 188.6C278.1 192.7 287.2 200.3 287 208.9C286.8 217.5 277.4 223.1 270.5 224.4C263.6 225.7 260.7 218.3 261.6 213.3C262.5 208.3 265 206.7 265 206.7" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M260 204C265 206.6 270.1 209.4 270.1 209.4Z" fill="#FBE6C6"/>
<path d="M260 204C265 206.6 270.1 209.4 270.1 209.4" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M266 150.4C266 144 269.8 140.1 277.8 140.1C285.8 140.1 292.6 147.6 290.8 158.8C289 170 295.1 178.7 288 183.7C280.9 188.6 273.6 185.6 271.3 181.8C271.3 181.8 263.7 183.8 264.3 171.9" fill="#FBE6C6"/>
<path d="M266 150.4C266 144 269.8 140.1 277.8 140.1C285.8 140.1 292.6 147.6 290.8 158.8C289 170 295.1 178.7 288 183.7C280.9 188.6 273.6 185.6 271.3 181.8C271.3 181.8 263.7 183.8 264.3 171.9" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M230.1 133.7C200.4 133.4 195.7 141.6 188.1 148.1C180.5 154.7 170.5 166.9 151.5 167.6C151.5 167.6 149 164.1 146.3 166.6C143.6 169.1 144.1 172.5 146 173.8C146 173.8 142.3 177.5 146.2 181.4C150.1 185.3 153.4 181.4 153.4 181.4C153.4 181.4 167.8 182.7 179.3 177.4C190.7 172 194.4 174.1 194.4 174.1C194.4 174.1 194.4 208.7 194.4 224.5C194.4 240.3 201.6 246.5 220.3 246.5C239 246.5 257.1 248.2 264.8 240.3C272.5 232.4 271.2 221.1 270.7 211.1C270.2 201.1 270.7 183 270.7 167.6C270.7 152.2 269.3 134 230.1 133.7Z" fill="#FBE6C6" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M242.3 244C242.3 248.8 241.8 250.6 242.1 254.1C240.5 255.2 239.9 257.1 240.4 258.7C241 260.7 244 262.7 250.3 262.6C260.7 262.7 263.5 260.1 263.4 252.5C263.3 249 263.2 244.3 263.1 240.3" fill="#DCCEB5"/>
<path d="M242.3 244C242.3 248.8 241.8 250.6 242.1 254.1C240.5 255.2 239.9 257.1 240.4 258.7C241 260.7 244 262.7 250.3 262.6C260.7 262.7 263.5 260.1 263.4 252.5C263.3 249 263.2 244.3 263.1 240.3" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M236.5 245.4C233.9 245.4 231.4 245.4 228.6 245.3C225.8 245.3 223 245.2 220.2 245.2C202.2 245.2 195.5 239.5 195.5 224.3C195.5 221.8 199.8 219.5 204.5 219.5C206.8 219.5 211.1 220.1 213.7 223.8C217.5 229 222.6 230.9 230 230.9C234.5 230.9 244.5 231.1 247.9 228.5C252.4 225.2 253.7 220.6 261.2 218.9C264.2 218.2 266.8 217.7 269.8 218.5C270 227.8 268.5 235.7 263.9 239.3C258.4 243.7 249.4 245.4 236.5 245.4Z" fill="#DCCEB5"/>
<path d="M243.9 141.2C250.9 141.1 254.6 136.8 254.1 132.6C253.5 128.5 250.6 122.7 237.5 123.4C231.7 123.7 228.8 127.5 226 127.5C223.2 127.4 217.7 119.4 212.9 119.9C208.1 120.4 207.8 123.8 208.6 125.4C208.6 125.4 204.3 126.7 206.7 132.3C209.1 137.9 214 139.8 218.8 140.4" fill="#FBE6C6"/>
<path d="M243.9 141.2C250.9 141.1 254.6 136.8 254.1 132.6C253.5 128.5 250.6 122.7 237.5 123.4C231.7 123.7 228.8 127.5 226 127.5C223.2 127.4 217.7 119.4 212.9 119.9C208.1 120.4 207.8 123.8 208.6 125.4C208.6 125.4 204.3 126.7 206.7 132.3C209.1 137.9 214 139.8 218.8 140.4" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M203.7 241.5C203.7 246.3 203.2 250.7 203.5 254.2C201.9 255.3 201.3 257.2 201.8 258.8C202.4 260.8 205.4 262.8 211.7 262.7C222.1 262.8 224.9 260.2 224.8 252.6C224.8 249.7 224.7 248.9 224.6 245.4" fill="#DCCEB5"/>
<path d="M203.7 241.5C203.7 246.3 203.2 250.7 203.5 254.2C201.9 255.3 201.3 257.2 201.8 258.8C202.4 260.8 205.4 262.8 211.7 262.7C222.1 262.8 224.9 260.2 224.8 252.6C224.8 249.7 224.7 248.9 224.6 245.4" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M194.8 22.9H140.2C138.3 22.9 136.8 21.4 136.8 19.5C136.8 17.6 138.3 16.1 140.2 16.1H194.8C196.7 16.1 198.2 17.6 198.2 19.5C198.2 21.4 196.7 22.9 194.8 22.9Z" fill="#4A4439"/>
<path d="M194.8 39.8H140.2C138.3 39.8 136.8 38.3 136.8 36.4C136.8 34.5 138.3 33 140.2 33H194.8C196.7 33 198.2 34.5 198.2 36.4C198.2 38.2 196.7 39.8 194.8 39.8Z" fill="#4A4439"/>
<path d="M194.8 56.6H140.2C138.3 56.6 136.8 55.1 136.8 53.2C136.8 51.3 138.3 49.8 140.2 49.8H194.8C196.7 49.8 198.2 51.3 198.2 53.2C198.2 55.1 196.7 56.6 194.8 56.6Z" fill="#4A4439"/>
<path d="M205.9 150.4C205.9 144 209.7 140.1 217.7 140.1C225.7 140.1 232.5 147.6 230.7 158.8C228.9 170 235 178.7 227.9 183.7C220.8 188.6 213.5 185.6 211.2 181.8C211.2 181.8 203.6 183.8 204.2 171.9" fill="#FBE6C6"/>
<path d="M205.9 150.4C205.9 144 209.7 140.1 217.7 140.1C225.7 140.1 232.5 147.6 230.7 158.8C228.9 170 235 178.7 227.9 183.7C220.8 188.6 213.5 185.6 211.2 181.8C211.2 181.8 203.6 183.8 204.2 171.9" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M104.3 166C115.9 165.4 125.9 161.4 131 156.7C136.8 151.4 139.9 146.2 134.7 141.6C129.6 137.1 124.6 141.5 124.6 141.5C124.6 141.5 122.6 138.4 119.6 140.7C116.6 143 118.8 145.2 118.8 145.2C118.8 145.2 115.2 150.2 103.9 150.7" fill="#FBC16C"/>
<path d="M104.3 166C115.9 165.4 125.9 161.4 131 156.7C136.8 151.4 139.9 146.2 134.7 141.6C129.6 137.1 124.6 141.5 124.6 141.5C124.6 141.5 122.6 138.4 119.6 140.7C116.6 143 118.8 145.2 118.8 145.2C118.8 145.2 115.2 150.2 103.9 150.7" stroke="#946F3A" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M49 139L45.5 139.6C44.5 139.8 43.5001 139.1 43.4001 138.1C43.2001 137.1 43.9001 136.1 44.9001 136L48.4001 135.4C49.4001 135.2 50.4 135.9 50.5 136.9C50.7 137.9 50.1 138.9 49 139Z" fill="#E68A4C"/>
<path d="M119.7 114.2C120.4 114 120.7 113.8 121.3 113.5" stroke="#FBD7A3" stroke-width="2.8533" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M114.5 115.6C115.4 115.4 114.9 115.6 115.7 115.4" stroke="#FBD7A3" stroke-width="2.8533" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M102.2 115.8C104.4 116.1 106.6 116.2 108.8 116.2" stroke="#FBD7A3" stroke-width="2.8533" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M88.1 111.8C90.5 112.9 93.1 113.8 95.8 114.5" stroke="#FBD7A3" stroke-width="2.8533" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M207.1 140.8C207.8 140.5 208.4 140.3 209 140" stroke="white" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M201.6 143.5C202.5 143 202.5 142.9 203.4 142.4" stroke="white" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M193.8 148.9C195.4 147.7 196.3 147 197.9 146" stroke="white" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M186.1 156.2C187.3 154.9 188.7 153.6 190.1 152.3" stroke="white" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M262.1 235.4C268.1 241.2 273.9 240.4 280.3 234.8C282.8 232.6 284.9 235.7 280.9 239.7C276.9 243.7 267.4 248 258.9 240.5" fill="#DCCEB5"/>
<path d="M262.1 235.4C268.1 241.2 273.9 240.4 280.3 234.8C282.8 232.6 284.9 235.7 280.9 239.7C276.9 243.7 267.4 248 258.9 240.5" stroke="#668794" stroke-width="2.2827" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { importFetchedAccount, importFetchedAccounts } from './importer';
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';

View file

@ -12,52 +12,48 @@ export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
export const ALERT_NOOP = 'ALERT_NOOP';
export function dismissAlert(alert) {
return {
type: ALERT_DISMISS,
alert,
};
}
export const dismissAlert = alert => ({
type: ALERT_DISMISS,
alert,
});
export function clearAlert() {
return {
type: ALERT_CLEAR,
};
}
export const clearAlert = () => ({
type: ALERT_CLEAR,
});
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
return {
type: ALERT_SHOW,
title,
message,
message_values,
};
}
export const showAlert = alert => ({
type: ALERT_SHOW,
alert,
});
export function showAlertForError(error, skipNotFound = false) {
export const showAlertForError = (error, skipNotFound = false) => {
if (error.response) {
const { data, status, statusText, headers } = error.response;
// Skip these errors as they are reflected in the UI
if (skipNotFound && (status === 404 || status === 410)) {
// Skip these errors as they are reflected in the UI
return { type: ALERT_NOOP };
}
// Rate limit errors
if (status === 429 && headers['x-ratelimit-reset']) {
const reset_date = new Date(headers['x-ratelimit-reset']);
return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
return showAlert({
title: messages.rateLimitedTitle,
message: messages.rateLimitedMessage,
values: { 'retry_time': new Date(headers['x-ratelimit-reset']) },
});
}
let message = statusText;
let title = `${status}`;
if (data.error) {
message = data.error;
}
return showAlert(title, message);
} else {
console.error(error);
return showAlert();
return showAlert({
title: `${status}`,
message: data.error || statusText,
});
}
console.error(error);
return showAlert({
title: messages.unexpectedTitle,
message: messages.unexpectedMessage,
});
}

View file

@ -1,4 +1,5 @@
import api from '../api';
import { normalizeAnnouncement } from './importer/normalizer';
export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';

View file

@ -1,17 +0,0 @@
export const APP_FOCUS = 'APP_FOCUS';
export const APP_UNFOCUS = 'APP_UNFOCUS';
export const focusApp = () => ({
type: APP_FOCUS,
});
export const unfocusApp = () => ({
type: APP_UNFOCUS,
});
export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE';
export const changeLayout = layout => ({
type: APP_LAYOUT_CHANGE,
layout,
});

View file

@ -0,0 +1,12 @@
import { createAction } from '@reduxjs/toolkit';
import type { LayoutType } from '../is_mobile';
export const focusApp = createAction('APP_FOCUS');
export const unfocusApp = createAction('APP_UNFOCUS');
interface ChangeLayoutPayload {
layout: LayoutType;
}
export const changeLayout =
createAction<ChangeLayoutPayload>('APP_LAYOUT_CHANGE');

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
import { openModal } from './modal';
@ -94,6 +95,6 @@ export function initBlockModal(account) {
account,
});
dispatch(openModal('BLOCK'));
dispatch(openModal({ modalType: 'BLOCK' }));
};
}

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST';

View file

@ -14,7 +14,10 @@ export function initBoostModal(props) {
privacy,
});
dispatch(openModal('BOOST', props));
dispatch(openModal({
modalType: 'BOOST',
modalProps: props,
}));
};
}

View file

@ -1,10 +1,12 @@
import { defineMessages } from 'react-intl';
import axios from 'axios';
import { throttle } from 'lodash';
import { defineMessages } from 'react-intl';
import api from 'mastodon/api';
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
import { tagHistory } from 'mastodon/settings';
import resizeImage from 'mastodon/utils/resize_image';
import { showAlert, showAlertForError } from './alerts';
import { useEmoji } from './emojis';
import { importFetchedAccounts, importFetchedStatus } from './importer';
@ -75,10 +77,13 @@ export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTIO
export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS';
export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
});
export const ensureComposeIsVisible = (getState, routerHistory) => {
@ -126,6 +131,15 @@ export function resetCompose() {
};
}
export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => {
dispatch({
type: COMPOSE_FOCUS,
defaultText,
});
ensureComposeIsVisible(getState, routerHistory);
};
export function mentionCompose(account, routerHistory) {
return (dispatch, getState) => {
dispatch({
@ -228,6 +242,13 @@ export function submitCompose(routerHistory) {
insertIfOnline('public');
insertIfOnline(`account:${response.data.account.id}`);
}
dispatch(showAlert({
message: messages.published,
action: messages.open,
dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
}));
}).catch(function (error) {
dispatch(submitComposeFail(error));
});
@ -257,63 +278,60 @@ export function submitComposeFail(error) {
export function uploadCompose(files) {
return function (dispatch, getState) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size + pending > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
dispatch(showAlert({ message: messages.uploadErrorLimit }));
return;
}
if (getState().getIn(['compose', 'poll'])) {
dispatch(showAlert(undefined, messages.uploadErrorPoll));
dispatch(showAlert({ message: messages.uploadErrorPoll }));
return;
}
dispatch(uploadComposeRequest());
for (const [i, f] of Array.from(files).entries()) {
for (const [i, file] of Array.from(files).entries()) {
if (media.size + i > 3) break;
resizeImage(f).then(file => {
const data = new FormData();
data.append('file', file);
// Account for disparity in size of original image and resized data
total += file.size - f.size;
const data = new FormData();
data.append('file', file);
return api(getState).post('/api/v2/media', data, {
onUploadProgress: function({ loaded }){
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ status, data }) => {
// If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded
api(getState).post('/api/v2/media', data, {
onUploadProgress: function({ loaded }){
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ status, data }) => {
// If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded
if (status === 200) {
dispatch(uploadComposeSuccess(data, f));
} else if (status === 202) {
dispatch(uploadComposeProcessing());
if (status === 200) {
dispatch(uploadComposeSuccess(data, file));
} else if (status === 202) {
dispatch(uploadComposeProcessing());
let tryCount = 1;
let tryCount = 1;
const poll = () => {
api(getState).get(`/api/v1/media/${data.id}`).then(response => {
if (response.status === 200) {
dispatch(uploadComposeSuccess(response.data, f));
} else if (response.status === 206) {
const retryAfter = (Math.log2(tryCount) || 1) * 1000;
tryCount += 1;
setTimeout(() => poll(), retryAfter);
}
}).catch(error => dispatch(uploadComposeFail(error)));
};
const poll = () => {
api(getState).get(`/api/v1/media/${data.id}`).then(response => {
if (response.status === 200) {
dispatch(uploadComposeSuccess(response.data, file));
} else if (response.status === 206) {
const retryAfter = (Math.log2(tryCount) || 1) * 1000;
tryCount += 1;
setTimeout(() => poll(), retryAfter);
}
}).catch(error => dispatch(uploadComposeFail(error)));
};
poll();
}
});
poll();
}
}).catch(error => dispatch(uploadComposeFail(error)));
}
};
@ -373,7 +391,10 @@ export function initMediaEditModal(id) {
id,
});
dispatch(openModal('FOCAL_POINT', { id }));
dispatch(openModal({
modalType: 'FOCAL_POINT',
modalProps: { id },
}));
};
}
@ -401,16 +422,12 @@ export function changeUploadCompose(id, params) {
// Editing already-attached media is deferred to editing the post itself.
// For simplicity's sake, fake an API reply.
if (media && !media.get('unattached')) {
let { description, focus } = params;
const data = media.toJS();
if (description) {
data.description = description;
}
const { focus, ...other } = params;
const data = { ...media.toJS(), ...other };
if (focus) {
focus = focus.split(',');
data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
const [x, y] = focus.split(',');
data.meta = { focus: { x: parseFloat(x), y: parseFloat(y) } };
}
dispatch(changeUploadComposeSuccess(data, true));

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import {
importFetchedAccounts,
importFetchedStatuses,

View file

@ -1,6 +1,7 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';

View file

@ -1,4 +1,5 @@
import api from '../api';
import { openModal } from './modal';
export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
@ -14,9 +15,12 @@ export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL';
export const initAddFilter = (status, { contextType }) => dispatch =>
dispatch(openModal('FILTER', {
statusId: status?.get('id'),
contextType: contextType,
dispatch(openModal({
modalType: 'FILTER',
modalProps: {
statusId: status?.get('id'),
contextType: contextType,
},
}));
export const fetchFilters = () => (dispatch, getState) => {

View file

@ -1,4 +1,5 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
export const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST';

View file

@ -81,7 +81,7 @@ export function importFetchedStatuses(statuses) {
}
if (status.poll && status.poll.id) {
pushUnique(polls, normalizePoll(status.poll));
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
}
}
@ -95,7 +95,7 @@ export function importFetchedStatuses(statuses) {
}
export function importFetchedPoll(poll) {
return dispatch => {
dispatch(importPolls([normalizePoll(poll)]));
return (dispatch, getState) => {
dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))]));
};
}

View file

@ -1,11 +1,12 @@
import escapeTextContentForBrowser from 'escape-html';
import emojify from '../../features/emoji/emoji';
import { unescapeHTML } from '../../utils/html';
import { expandSpoilers } from '../../initial_state';
import { unescapeHTML } from '../../utils/html';
const domParser = new DOMParser();
const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => {
obj[`:${emoji.shortcode}:`] = emoji;
return obj;
}, {});
@ -19,7 +20,7 @@ export function searchTextFromRawStatus (status) {
export function normalizeAccount(account) {
account = { ...account };
const emojiMap = makeEmojiMap(account);
const emojiMap = makeEmojiMap(account.emojis);
const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
@ -75,6 +76,7 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
normalStatus.hidden = normalOldStatus.get('hidden');
normalStatus.translation = normalOldStatus.get('translation');
} else {
// If the status has a CW but no contents, treat the CW as if it were the
// status' contents, to avoid having a CW toggle with seemingly no effect.
@ -85,7 +87,7 @@ export function normalizeStatus(status, normalOldStatus) {
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus);
const emojiMap = makeEmojiMap(normalStatus.emojis);
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
@ -93,25 +95,71 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
}
if (normalOldStatus) {
const list = normalOldStatus.get('media_attachments');
if (normalStatus.media_attachments && list) {
normalStatus.media_attachments.forEach(item => {
const oldItem = list.find(i => i.get('id') === item.id);
if (oldItem && oldItem.get('description') === item.description) {
item.translation = oldItem.get('translation')
}
});
}
}
return normalStatus;
}
export function normalizePoll(poll) {
const normalPoll = { ...poll };
const emojiMap = makeEmojiMap(normalPoll);
export function normalizeStatusTranslation(translation, status) {
const emojiMap = makeEmojiMap(status.get('emojis').toJS());
normalPoll.options = poll.options.map((option, index) => ({
...option,
voted: poll.own_votes && poll.own_votes.includes(index),
title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}));
const normalTranslation = {
detected_source_language: translation.detected_source_language,
language: translation.language,
provider: translation.provider,
contentHtml: emojify(translation.content, emojiMap),
spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap),
spoiler_text: translation.spoiler_text,
};
return normalTranslation;
}
export function normalizePoll(poll, normalOldPoll) {
const normalPoll = { ...poll };
const emojiMap = makeEmojiMap(poll.emojis);
normalPoll.options = poll.options.map((option, index) => {
const normalOption = {
...option,
voted: poll.own_votes && poll.own_votes.includes(index),
titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}
if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) {
normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']);
}
return normalOption
});
return normalPoll;
}
export function normalizePollOptionTranslation(translation, poll) {
const emojiMap = makeEmojiMap(poll.get('emojis').toJS());
const normalTranslation = {
...translation,
titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap),
};
return normalTranslation;
}
export function normalizeAnnouncement(announcement) {
const normalAnnouncement = { ...announcement };
const emojiMap = makeEmojiMap(normalAnnouncement);
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);

View file

@ -1,4 +1,5 @@
import api from '../api';
import { importFetchedAccounts, importFetchedStatus } from './importer';
export const REBLOG_REQUEST = 'REBLOG_REQUEST';

View file

@ -1,6 +1,7 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer';
export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
@ -150,10 +151,10 @@ export const createListFail = error => ({
error,
});
export const updateList = (id, title, shouldReset, replies_policy) => (dispatch, getState) => {
export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => {
dispatch(updateListRequest(id));
api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy }).then(({ data }) => {
api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
dispatch(updateListSuccess(data));
if (shouldReset) {

View file

@ -1,8 +1,10 @@
import api from '../api';
import { debounce } from 'lodash';
import compareId from '../compare_id';
import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash';
import api from '../api';
import { compareId } from '../compare_id';
export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL';
@ -55,7 +57,7 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
client.open('POST', '/api/v1/markers', false);
client.setRequestHeader('Content-Type', 'application/json');
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
client.SUBMIT(JSON.stringify(params));
client.send(JSON.stringify(params));
} catch (e) {
// Do not make the BeforeUnload handler error out
}

View file

@ -1,18 +0,0 @@
export const MODAL_OPEN = 'MODAL_OPEN';
export const MODAL_CLOSE = 'MODAL_CLOSE';
export function openModal(type, props) {
return {
type: MODAL_OPEN,
modalType: type,
modalProps: props,
};
}
export function closeModal(type, options = { ignoreFocus: false }) {
return {
type: MODAL_CLOSE,
modalType: type,
ignoreFocus: options.ignoreFocus,
};
}

View file

@ -0,0 +1,17 @@
import { createAction } from '@reduxjs/toolkit';
import type { MODAL_COMPONENTS } from '../features/ui/components/modal_root';
export type ModalType = keyof typeof MODAL_COMPONENTS;
interface OpenModalPayload {
modalType: ModalType;
modalProps: unknown;
}
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
interface CloseModalPayload {
modalType: ModalType | undefined;
ignoreFocus: boolean;
}
export const closeModal = createAction<CloseModalPayload>('MODAL_CLOSE');

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
import { openModal } from './modal';
@ -96,7 +97,7 @@ export function initMuteModal(account) {
account,
});
dispatch(openModal('MUTE'));
dispatch(openModal({ modalType: 'MUTE' }));
};
}

View file

@ -1,5 +1,15 @@
import { IntlMessageFormat } from 'intl-messageformat';
import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { compareId } from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import api, { getLinks } from '../api';
import IntlMessageFormat from 'intl-messageformat';
import { unescapeHTML } from '../utils/html';
import { requestNotificationPermission } from '../utils/notifications';
import { fetchFollowRequests, fetchRelationships } from './accounts';
import {
importFetchedAccount,
@ -9,12 +19,6 @@ import {
} from './importer';
import { submitMarkers } from './markers';
import { saveSettings } from './settings';
import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import compareId from 'mastodon/compare_id';
import { requestNotificationPermission } from '../utils/notifications';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';

View file

@ -20,9 +20,10 @@ export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
* @param {string} accountId
* @param {string} playerType
* @param {MediaProps} props
* @return {object}
* @returns {object}
*/
export const deployPictureInPicture = (statusId, accountId, playerType, props) => {
// @ts-expect-error
return (dispatch, getState) => {
// Do not open a player for a toot that does not exist
if (getState().hasIn(['statuses', statusId])) {

View file

@ -1,12 +1,12 @@
import api from '../api';
import { me } from '../initial_state';
import { importFetchedStatuses } from './importer';
export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
import { me } from '../initial_state';
export function fetchPinnedStatuses() {
return (dispatch, getState) => {
dispatch(fetchPinnedStatusesRequest());

View file

@ -1,4 +1,5 @@
import api from '../api';
import { importFetchedPoll } from './importer';
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';

View file

@ -1,5 +1,5 @@
import { setAlerts } from './setter';
import { saveSettings } from './registerer';
import { setAlerts } from './setter';
export function changeAlerts(path, value) {
return dispatch => {

View file

@ -1,14 +1,15 @@
import api from '../../api';
import { decode as decodeBase64 } from '../../utils/base64';
import { pushNotificationsSetting } from '../../settings';
import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
import { me } from '../../initial_state';
import { pushNotificationsSetting } from '../../settings';
import { decode as decodeBase64 } from '../../utils/base64';
import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
// Taken from https://www.npmjs.com/package/web-push
const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/-/g, '+')
.replace(/_/g, '/');
return decodeBase64(base64);

View file

@ -1,4 +1,5 @@
import api from '../api';
import { openModal } from './modal';
export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
@ -6,9 +7,12 @@ export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL';
export const initReport = (account, status) => dispatch =>
dispatch(openModal('REPORT', {
accountId: account.get('id'),
statusId: status?.get('id'),
dispatch(openModal({
modalType: 'REPORT',
modalProps: {
accountId: account.get('id'),
statusId: status?.get('id'),
},
}));
export const submitReport = (params, onSuccess, onFail) => (dispatch, getState) => {

View file

@ -1,4 +1,5 @@
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
@ -14,6 +15,9 @@ export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
export const SEARCH_RESULT_CLICK = 'SEARCH_RESULT_CLICK';
export const SEARCH_RESULT_FORGET = 'SEARCH_RESULT_FORGET';
export function changeSearch(value) {
return {
type: SEARCH_CHANGE,
@ -27,7 +31,7 @@ export function clearSearch() {
};
}
export function submitSearch() {
export function submitSearch(type) {
return (dispatch, getState) => {
const value = getState().getIn(['search', 'value']);
const signedIn = !!getState().getIn(['meta', 'me']);
@ -44,6 +48,7 @@ export function submitSearch() {
q: value,
resolve: signedIn,
limit: 5,
type,
},
}).then(response => {
if (response.data.accounts) {
@ -130,3 +135,47 @@ export const expandSearchFail = error => ({
export const showSearch = () => ({
type: SEARCH_SHOW,
});
export const openURL = (value, history, onFailure) => (dispatch, getState) => {
const signedIn = !!getState().getIn(['meta', 'me']);
if (!signedIn) {
return;
}
dispatch(fetchSearchRequest());
api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
if (response.data.accounts?.length > 0) {
dispatch(importFetchedAccounts(response.data.accounts));
history.push(`/@${response.data.accounts[0].acct}`);
} else if (response.data.statuses?.length > 0) {
dispatch(importFetchedStatuses(response.data.statuses));
history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
} else if (onFailure) {
onFailure();
}
dispatch(fetchSearchSuccess(response.data, value));
}).catch(err => {
dispatch(fetchSearchFail(err));
if (onFailure) {
onFailure();
}
});
};
export const clickSearchResult = (q, type) => ({
type: SEARCH_RESULT_CLICK,
result: {
type,
q,
},
});
export const forgetSearchResult = q => ({
type: SEARCH_RESULT_FORGET,
q,
});

View file

@ -1,10 +1,15 @@
import api from '../api';
import { importFetchedAccount } from './importer';
export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST';
export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS';
export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL';
export const SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST = 'SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST';
export const SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS = 'SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS';
export const SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL = 'SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL';
export const EXTENDED_DESCRIPTION_REQUEST = 'EXTENDED_DESCRIPTION_REQUEST';
export const EXTENDED_DESCRIPTION_SUCCESS = 'EXTENDED_DESCRIPTION_SUCCESS';
export const EXTENDED_DESCRIPTION_FAIL = 'EXTENDED_DESCRIPTION_FAIL';
@ -14,6 +19,10 @@ export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SU
export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL';
export const fetchServer = () => (dispatch, getState) => {
if (getState().getIn(['server', 'server', 'isLoading'])) {
return;
}
dispatch(fetchServerRequest());
api(getState)
@ -37,7 +46,34 @@ const fetchServerFail = error => ({
error,
});
export const fetchServerTranslationLanguages = () => (dispatch, getState) => {
dispatch(fetchServerTranslationLanguagesRequest());
api(getState)
.get('/api/v1/instance/translation_languages').then(({ data }) => {
dispatch(fetchServerTranslationLanguagesSuccess(data));
}).catch(err => dispatch(fetchServerTranslationLanguagesFail(err)));
};
const fetchServerTranslationLanguagesRequest = () => ({
type: SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST,
});
const fetchServerTranslationLanguagesSuccess = translationLanguages => ({
type: SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS,
translationLanguages,
});
const fetchServerTranslationLanguagesFail = error => ({
type: SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL,
error,
});
export const fetchExtendedDescription = () => (dispatch, getState) => {
if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) {
return;
}
dispatch(fetchExtendedDescriptionRequest());
api(getState)
@ -61,6 +97,10 @@ const fetchExtendedDescriptionFail = error => ({
});
export const fetchDomainBlocks = () => (dispatch, getState) => {
if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) {
return;
}
dispatch(fetchDomainBlocksRequest());
api(getState)

View file

@ -1,5 +1,7 @@
import api from '../api';
import { debounce } from 'lodash';
import api from '../api';
import { showAlertForError } from './alerts';
export const SETTING_CHANGE = 'SETTING_CHANGE';

View file

@ -1,8 +1,8 @@
import api from '../api';
import { deleteFromTimelines } from './timelines';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
import { deleteFromTimelines } from './timelines';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@ -343,7 +343,8 @@ export const translateStatusFail = (id, error) => ({
error,
});
export const undoStatusTranslation = id => ({
export const undoStatusTranslation = (id, pollId) => ({
type: STATUS_TRANSLATE_UNDO,
id,
pollId,
});

View file

@ -1,4 +1,5 @@
import { Iterable, fromJS } from 'immutable';
import { hydrateCompose } from './compose';
import { importFetchedAccounts } from './importer';

View file

@ -1,6 +1,17 @@
// @ts-check
import { getLocale } from '../locales';
import { connectStream } from '../stream';
import {
fetchAnnouncements,
updateAnnouncements,
updateReaction as updateAnnouncementsReaction,
deleteAnnouncement,
} from './announcements';
import { updateConversations } from './conversations';
import { updateNotifications, expandNotifications } from './notifications';
import { updateStatus } from './statuses';
import {
updateTimeline,
deleteFromTimelines,
@ -12,22 +23,10 @@ import {
fillCommunityTimelineGaps,
fillListTimelineGaps,
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
import { updateStatus } from './statuses';
import {
fetchAnnouncements,
updateAnnouncements,
updateReaction as updateAnnouncementsReaction,
deleteAnnouncement,
} from './announcements';
import { getLocale } from '../locales';
const { messages } = getLocale();
/**
* @param {number} max
* @return {number}
* @returns {number}
*/
const randomUpTo = max =>
Math.floor(Math.random() * Math.floor(max));
@ -40,19 +39,24 @@ const randomUpTo = max =>
* @param {function(Function, Function): void} [options.fallback]
* @param {function(): void} [options.fillGaps]
* @param {function(object): boolean} [options.accept]
* @return {function(): void}
* @returns {function(): void}
*/
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) =>
connectStream(channelName, params, (dispatch, getState) => {
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => {
const { messages } = getLocale();
return connectStream(channelName, params, (dispatch, getState) => {
const locale = getState().getIn(['meta', 'locale']);
// @ts-expect-error
let pollingId;
/**
* @param {function(Function, Function): void} fallback
*/
const useFallback = fallback => {
fallback(dispatch, () => {
// eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook
pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
});
};
@ -61,9 +65,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
onConnect() {
dispatch(connectTimeline(timelineId));
// @ts-expect-error
if (pollingId) {
clearTimeout(pollingId);
pollingId = null;
// @ts-ignore
clearTimeout(pollingId); pollingId = null;
}
if (options.fillGaps) {
@ -75,31 +80,38 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
dispatch(disconnectTimeline(timelineId));
if (options.fallback) {
// @ts-expect-error
pollingId = setTimeout(() => useFallback(options.fallback), randomUpTo(40000));
}
},
onReceive (data) {
switch(data.event) {
onReceive(data) {
switch (data.event) {
case 'update':
// @ts-expect-error
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
break;
case 'status.update':
// @ts-expect-error
dispatch(updateStatus(JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
case 'notification':
// @ts-expect-error
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
break;
case 'conversation':
// @ts-expect-error
dispatch(updateConversations(JSON.parse(data.payload)));
break;
case 'announcement':
// @ts-expect-error
dispatch(updateAnnouncements(JSON.parse(data.payload)));
break;
case 'announcement.reaction':
// @ts-expect-error
dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
break;
case 'announcement.delete':
@ -109,27 +121,31 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
},
};
});
};
/**
* @param {Function} dispatch
* @param {function(): void} done
*/
const refreshHomeTimelineAndNotification = (dispatch, done) => {
// @ts-expect-error
dispatch(expandHomeTimeline({}, () =>
// @ts-expect-error
dispatch(expandNotifications({}, () =>
dispatch(fetchAnnouncements(done))))));
};
/**
* @return {function(): void}
* @returns {function(): void}
*/
export const connectUserStream = () =>
// @ts-expect-error
connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps });
/**
* @param {Object} options
* @param {boolean} [options.onlyMedia]
* @return {function(): void}
* @returns {function(): void}
*/
export const connectCommunityStream = ({ onlyMedia } = {}) =>
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) });
@ -138,7 +154,7 @@ export const connectCommunityStream = ({ onlyMedia } = {}) =>
* @param {Object} options
* @param {boolean} [options.onlyMedia]
* @param {boolean} [options.onlyRemote]
* @return {function(): void}
* @returns {function(): void}
*/
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) =>
connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote }) });
@ -148,20 +164,20 @@ export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) =>
* @param {string} tagName
* @param {boolean} onlyLocal
* @param {function(object): boolean} accept
* @return {function(): void}
* @returns {function(): void}
*/
export const connectHashtagStream = (columnId, tagName, onlyLocal, accept) =>
connectTimelineStream(`hashtag:${columnId}${onlyLocal ? ':local' : ''}`, `hashtag${onlyLocal ? ':local' : ''}`, { tag: tagName }, { accept });
/**
* @return {function(): void}
* @returns {function(): void}
*/
export const connectDirectStream = () =>
connectTimelineStream('direct', 'direct');
/**
* @param {string} listId
* @return {function(): void}
* @returns {function(): void}
*/
export const connectListStream = listId =>
connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) });

View file

@ -1,6 +1,7 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';

View file

@ -1,9 +1,11 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import api, { getLinks } from 'mastodon/api';
import { compareId } from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
import api, { getLinks } from 'mastodon/api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@ -143,7 +145,7 @@ export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) {
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, tagged, max_id: maxId });
export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId });
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);

View file

@ -1,4 +1,5 @@
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST';

View file

@ -2,6 +2,7 @@
import axios from 'axios';
import LinkHeader from 'http-link-header';
import ready from './ready';
/**
@ -36,7 +37,7 @@ const setCSRFHeader = () => {
ready(setCSRFHeader);
/**
* @param {() => import('immutable').Map} getState
* @param {() => import('immutable').Map<string,any>} getState
* @returns {import('axios').RawAxiosRequestHeaders}
*/
const authorizationHeaderFromState = getState => {
@ -52,7 +53,7 @@ const authorizationHeaderFromState = getState => {
};
/**
* @param {() => import('immutable').Map} getState
* @param {() => import('immutable').Map<string,any>} getState
* @returns {import('axios').AxiosInstance}
*/
export default function api(getState) {

View file

@ -1,47 +0,0 @@
import 'intl';
import 'intl/locale-data/jsonp/en';
import 'es6-symbol/implement';
import includes from 'array-includes';
import assign from 'object-assign';
import values from 'object.values';
import isNaN from 'is-nan';
import { decode as decodeBase64 } from './utils/base64';
import promiseFinally from 'promise.prototype.finally';
if (!Array.prototype.includes) {
includes.shim();
}
if (!Object.assign) {
Object.assign = assign;
}
if (!Object.values) {
values.shim();
}
if (!Number.isNaN) {
Number.isNaN = isNaN;
}
promiseFinally.shim();
if (!HTMLCanvasElement.prototype.toBlob) {
const BASE64_MARKER = ';base64,';
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
value(callback, type = 'image/png', quality) {
const dataURL = this.toDataURL(type, quality);
let data;
if (dataURL.indexOf(BASE64_MARKER) >= 0) {
const [, base64] = dataURL.split(BASE64_MARKER);
data = decodeBase64(base64);
} else {
[, data] = dataURL.split(',');
}
callback(new Blob([data], { type }));
},
});
}

View file

@ -84,12 +84,11 @@ const DIGIT_CHARACTERS = [
'~',
];
export const decode83 = (str) => {
export const decode83 = (str: string) => {
let value = 0;
let c, digit;
let digit;
for (let i = 0; i < str.length; i++) {
c = str[i];
for (const c of str) {
digit = DIGIT_CHARACTERS.indexOf(c);
value = value * 83 + digit;
}
@ -97,13 +96,13 @@ export const decode83 = (str) => {
return value;
};
export const intToRGB = int => ({
r: Math.max(0, (int >> 16)),
export const intToRGB = (int: number) => ({
r: Math.max(0, int >> 16),
g: Math.max(0, (int >> 8) & 255),
b: Math.max(0, (int & 255)),
b: Math.max(0, int & 255),
});
export const getAverageFromBlurhash = blurhash => {
export const getAverageFromBlurhash = (blurhash: string) => {
if (!blurhash) {
return null;
}

View file

@ -1,7 +1,7 @@
import Rails from '@rails/ujs';
import 'font-awesome/css/font-awesome.css';
export function start() {
require('font-awesome/css/font-awesome.css');
require.context('../images/', true);
try {

View file

@ -1,4 +1,4 @@
export default function compareId (id1, id2) {
export function compareId(id1: string, id2: string) {
if (id1 === id2) {
return 0;
}

View file

@ -3,6 +3,8 @@
exports[`<AvatarOverlay renders a overlay avatar 1`] = `
<div
className="account__avatar-overlay"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": 46,
@ -15,8 +17,6 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
>
<div
className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": "36px",
@ -35,8 +35,6 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
>
<div
className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": "24px",

View file

@ -1,5 +1,5 @@
import React from 'react';
import renderer from 'react-test-renderer';
import AutosuggestEmoji from '../autosuggest_emoji';
describe('<AutosuggestEmoji />', () => {

View file

@ -1,7 +1,8 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import Avatar from '../avatar';
import renderer from 'react-test-renderer';
import { Avatar } from '../avatar';
describe('<Avatar />', () => {
const account = fromJS({

View file

@ -1,7 +1,8 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import AvatarOverlay from '../avatar_overlay';
import renderer from 'react-test-renderer';
import { AvatarOverlay } from '../avatar_overlay';
describe('<AvatarOverlay', () => {
const account = fromJS({

View file

@ -1,6 +1,6 @@
import { render, fireEvent, screen } from '@testing-library/react';
import React from 'react';
import renderer from 'react-test-renderer';
import Button from '../button';
describe('<Button />', () => {

View file

@ -1,7 +1,8 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import DisplayName from '../display_name';
import renderer from 'react-test-renderer';
import { DisplayName } from '../display_name';
describe('<DisplayName />', () => {
it('renders display name + account name', () => {

View file

@ -1,157 +0,0 @@
import React, { Fragment } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
import DisplayName from './display_name';
import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state';
import RelativeTimestamp from './relative_timestamp';
import Skeleton from 'mastodon/components/skeleton';
import { Link } from 'react-router-dom';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
});
export default @injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
size: PropTypes.number,
account: ImmutablePropTypes.map,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onMuteNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
actionIcon: PropTypes.string,
actionTitle: PropTypes.string,
defaultAction: PropTypes.string,
onActionClick: PropTypes.func,
};
static defaultProps = {
size: 46,
};
handleFollow = () => {
this.props.onFollow(this.props.account);
};
handleBlock = () => {
this.props.onBlock(this.props.account);
};
handleMute = () => {
this.props.onMute(this.props.account);
};
handleMuteNotifications = () => {
this.props.onMuteNotifications(this.props.account, true);
};
handleUnmuteNotifications = () => {
this.props.onMuteNotifications(this.props.account, false);
};
handleAction = () => {
this.props.onActionClick(this.props.account);
};
render () {
const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size } = this.props;
if (!account) {
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Skeleton width={36} height={36} /></div>
<DisplayName />
</div>
</div>
</div>
);
}
if (hidden) {
return (
<Fragment>
{account.get('display_name')}
{account.get('username')}
</Fragment>
);
}
let buttons;
if (actionIcon) {
if (onActionClick) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
}
} else if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
} else if (blocking) {
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
} else if (muting) {
let hidingNotificationsButton;
if (account.getIn(['relationship', 'muting_notifications'])) {
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />;
} else {
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />;
}
buttons = (
<Fragment>
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
{hidingNotificationsButton}
</Fragment>
);
} else if (defaultAction === 'mute') {
buttons = <IconButton icon='volume-off' title={intl.formatMessage(messages.mute, { name: account.get('username') })} onClick={this.handleMute} />;
} else if (defaultAction === 'block') {
buttons = <IconButton icon='lock' title={intl.formatMessage(messages.block, { name: account.get('username') })} onClick={this.handleBlock} />;
} else if (!account.get('moved') || following) {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
}
}
let mute_expires_at;
if (account.get('mute_expires_at')) {
mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
}
return (
<div className='account'>
<div className='account__wrapper'>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={size} /></div>
{mute_expires_at}
<DisplayName account={account} />
</Link>
<div className='account__relationship'>
{buttons}
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,190 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { EmptyAccount } from 'mastodon/components/empty_account';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { me } from '../initial_state';
import { Avatar } from './avatar';
import Button from './button';
import { FollowersCounter } from './counters';
import { DisplayName } from './display_name';
import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' },
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
block: { id: 'account.block_short', defaultMessage: 'Block' },
});
class Account extends ImmutablePureComponent {
static propTypes = {
size: PropTypes.number,
account: ImmutablePropTypes.map,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onMuteNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
minimal: PropTypes.bool,
actionIcon: PropTypes.string,
actionTitle: PropTypes.string,
defaultAction: PropTypes.string,
onActionClick: PropTypes.func,
withBio: PropTypes.bool,
};
static defaultProps = {
size: 46,
};
handleFollow = () => {
this.props.onFollow(this.props.account);
};
handleBlock = () => {
this.props.onBlock(this.props.account);
};
handleMute = () => {
this.props.onMute(this.props.account);
};
handleMuteNotifications = () => {
this.props.onMuteNotifications(this.props.account, true);
};
handleUnmuteNotifications = () => {
this.props.onMuteNotifications(this.props.account, false);
};
handleAction = () => {
this.props.onActionClick(this.props.account);
};
render () {
const { account, intl, hidden, withBio, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props;
if (!account) {
return <EmptyAccount size={size} minimal={minimal} />;
}
if (hidden) {
return (
<>
{account.get('display_name')}
{account.get('username')}
</>
);
}
let buttons;
if (actionIcon && onActionClick) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
} else if (!actionIcon && account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={this.handleFollow} />;
} else if (blocking) {
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
} else if (muting) {
let hidingNotificationsButton;
if (account.getIn(['relationship', 'muting_notifications'])) {
hidingNotificationsButton = <Button text={intl.formatMessage(messages.unmute_notifications)} onClick={this.handleUnmuteNotifications} />;
} else {
hidingNotificationsButton = <Button text={intl.formatMessage(messages.mute_notifications)} onClick={this.handleMuteNotifications} />;
}
buttons = (
<>
<Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />
{hidingNotificationsButton}
</>
);
} else if (defaultAction === 'mute') {
buttons = <Button title={intl.formatMessage(messages.mute)} onClick={this.handleMute} />;
} else if (defaultAction === 'block') {
buttons = <Button text={intl.formatMessage(messages.block)} onClick={this.handleBlock} />;
} else if (!account.get('moved') || following) {
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
}
}
let muteTimeRemaining;
if (account.get('mute_expires_at')) {
muteTimeRemaining = <>· <RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></>;
}
let verification;
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
if (firstVerifiedField) {
verification = <VerifiedBadge link={firstVerifiedField.get('value')} />;
}
return (
<div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={size} />
</div>
<div className='account__contents'>
<DisplayName account={account} />
{!minimal && (
<div className='account__details'>
<ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} /> {verification} {muteTimeRemaining}
</div>
)}
</div>
</Link>
{!minimal && (
<div className='account__relationship'>
{buttons}
</div>
)}
</div>
{withBio && (account.get('note').length > 0 ? (
<div
className='account__note translate'
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/>
) : (
<div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
))}
</div>
);
}
}
export default injectIntl(Account);

View file

@ -1,10 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { PureComponent } from 'react';
import { FormattedNumber } from 'react-intl';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import classNames from 'classnames';
import Skeleton from 'mastodon/components/skeleton';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import api from 'mastodon/api';
import { Skeleton } from 'mastodon/components/skeleton';
const percIncrease = (a, b) => {
let percent;
@ -24,7 +28,7 @@ const percIncrease = (a, b) => {
return percent;
};
export default class Counter extends React.PureComponent {
export default class Counter extends PureComponent {
static propTypes = {
measure: PropTypes.string.isRequired,
@ -62,25 +66,25 @@ export default class Counter extends React.PureComponent {
if (loading) {
content = (
<React.Fragment>
<>
<span className='sparkline__value__total'><Skeleton width={43} /></span>
<span className='sparkline__value__change'><Skeleton width={43} /></span>
</React.Fragment>
</>
);
} else {
const measure = data[0];
const percentChange = measure.previous_total && percIncrease(measure.previous_total * 1, measure.total * 1);
content = (
<React.Fragment>
<>
<span className='sparkline__value__total'>{measure.human_value || <FormattedNumber value={measure.total} />}</span>
{measure.previous_total && (<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>)}
</React.Fragment>
</>
);
}
const inner = (
<React.Fragment>
<>
<div className='sparkline__value'>
{content}
</div>
@ -96,7 +100,7 @@ export default class Counter extends React.PureComponent {
</Sparklines>
)}
</div>
</React.Fragment>
</>
);
if (href) {

View file

@ -1,11 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedNumber } from 'react-intl';
import { roundTo10 } from 'mastodon/utils/numbers';
import Skeleton from 'mastodon/components/skeleton';
import { PureComponent } from 'react';
export default class Dimension extends React.PureComponent {
import { FormattedNumber } from 'react-intl';
import api from 'mastodon/api';
import { Skeleton } from 'mastodon/components/skeleton';
import { roundTo10 } from 'mastodon/utils/numbers';
export default class Dimension extends PureComponent {
static propTypes = {
dimension: PropTypes.string.isRequired,

View file

@ -0,0 +1,91 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedNumber, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import api from 'mastodon/api';
import { Skeleton } from 'mastodon/components/skeleton';
export default class ImpactReport extends PureComponent {
static propTypes = {
domain: PropTypes.string.isRequired,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { domain } = this.props;
const params = {
domain: domain,
include_subdomains: true,
};
api().post('/api/v1/admin/measures', {
keys: ['instance_accounts', 'instance_follows', 'instance_followers'],
start_at: null,
end_at: null,
instance_accounts: params,
instance_follows: params,
instance_followers: params,
}).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { loading, data } = this.state;
return (
<div className='dimension'>
<h4><FormattedMessage id='admin.impact_report.title' defaultMessage='Impact summary' /></h4>
<table>
<tbody>
<tr className='dimension__item'>
<td className='dimension__item__key'>
<FormattedMessage id='admin.impact_report.instance_accounts' defaultMessage='Accounts profiles this would delete' />
</td>
<td className='dimension__item__value'>
{loading ? <Skeleton width={60} /> : <FormattedNumber value={data[0].total} />}
</td>
</tr>
<tr className={classNames('dimension__item', { negative: !loading && data[1].total > 0 })}>
<td className='dimension__item__key'>
<FormattedMessage id='admin.impact_report.instance_follows' defaultMessage='Followers their users would lose' />
</td>
<td className='dimension__item__value'>
{loading ? <Skeleton width={60} /> : <FormattedNumber value={data[1].total} />}
</td>
</tr>
<tr className={classNames('dimension__item', { negative: !loading && data[2].total > 0 })}>
<td className='dimension__item__key'>
<FormattedMessage id='admin.impact_report.instance_followers' defaultMessage='Followers our users would lose' />
</td>
<td className='dimension__item__value'>
{loading ? <Skeleton width={60} /> : <FormattedNumber value={data[2].total} />}
</td>
</tr>
</tbody>
</table>
</div>
);
}
}

View file

@ -1,16 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import api from 'mastodon/api';
const messages = defineMessages({
other: { id: 'report.categories.other', defaultMessage: 'Other' },
spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
});
class Category extends React.PureComponent {
class Category extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
@ -33,7 +36,7 @@ class Category extends React.PureComponent {
const { id, text, disabled, selected, children } = this.props;
return (
<div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
<div tabIndex={0} role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
{selected && <input type='hidden' name='report[category]' value={id} />}
<div className='report-reason-selector__category__label'>
@ -52,7 +55,7 @@ class Category extends React.PureComponent {
}
class Rule extends React.PureComponent {
class Rule extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
@ -74,7 +77,7 @@ class Rule extends React.PureComponent {
const { id, text, disabled, selected } = this.props;
return (
<div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
<div tabIndex={0} role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
<span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} />
{selected && <input type='hidden' name='report[rule_ids][]' value={id} />}
{text}
@ -84,8 +87,7 @@ class Rule extends React.PureComponent {
}
export default @injectIntl
class ReportReasonSelector extends React.PureComponent {
class ReportReasonSelector extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
@ -157,3 +159,5 @@ class ReportReasonSelector extends React.PureComponent {
}
}
export default injectIntl(ReportReasonSelector);

View file

@ -1,8 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { PureComponent } from 'react';
import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
import classNames from 'classnames';
import api from 'mastodon/api';
import { roundTo10 } from 'mastodon/utils/numbers';
const dateForCohort = cohort => {
@ -14,7 +17,7 @@ const dateForCohort = cohort => {
}
};
export default class Retention extends React.PureComponent {
export default class Retention extends PureComponent {
static propTypes = {
start_at: PropTypes.string,

View file

@ -1,11 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import api from 'mastodon/api';
import Hashtag from 'mastodon/components/hashtag';
export default class Trends extends React.PureComponent {
export default class Trends extends PureComponent {
static propTypes = {
limit: PropTypes.number.isRequired,

View file

@ -1,76 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ShortNumber from 'mastodon/components/short_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
import { reduceMotion } from 'mastodon/initial_state';
const obfuscatedCount = count => {
if (count < 0) {
return 0;
} else if (count <= 1) {
return count;
} else {
return '1+';
}
};
export default class AnimatedNumber extends React.PureComponent {
static propTypes = {
value: PropTypes.number.isRequired,
obfuscate: PropTypes.bool,
};
state = {
direction: 1,
};
componentWillReceiveProps (nextProps) {
if (nextProps.value > this.props.value) {
this.setState({ direction: 1 });
} else if (nextProps.value < this.props.value) {
this.setState({ direction: -1 });
}
}
willEnter = () => {
const { direction } = this.state;
return { y: -1 * direction };
};
willLeave = () => {
const { direction } = this.state;
return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
};
render () {
const { value, obfuscate } = this.props;
const { direction } = this.state;
if (reduceMotion) {
return obfuscate ? obfuscatedCount(value) : <ShortNumber value={value} />;
}
const styles = [{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
}];
return (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
))}
</span>
)}
</TransitionMotion>
);
}
}

View file

@ -0,0 +1,81 @@
import { useCallback, useState } from 'react';
import { TransitionMotion, spring } from 'react-motion';
import { reduceMotion } from '../initial_state';
import { ShortNumber } from './short_number';
const obfuscatedCount = (count: number) => {
if (count < 0) {
return 0;
} else if (count <= 1) {
return count;
} else {
return '1+';
}
};
interface Props {
value: number;
obfuscate?: boolean;
}
export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
const [previousValue, setPreviousValue] = useState(value);
const [direction, setDirection] = useState<1 | -1>(1);
if (previousValue !== value) {
setPreviousValue(value);
setDirection(value > previousValue ? 1 : -1);
}
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
const willLeave = useCallback(
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
[direction],
);
if (reduceMotion) {
return obfuscate ? (
<>{obfuscatedCount(value)}</>
) : (
<ShortNumber value={value} />
);
}
const styles = [
{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
},
];
return (
<TransitionMotion
styles={styles}
willEnter={willEnter}
willLeave={willLeave}
>
{(items) => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span
key={key}
style={{
position: direction * style.y > 0 ? 'absolute' : 'static',
transform: `translateY(${style.y * 100}%)`,
}}
>
{obfuscate ? (
obfuscatedCount(data as number)
) : (
<ShortNumber value={data as number} />
)}
</span>
))}
</span>
)}
</TransitionMotion>
);
};

View file

@ -1,10 +1,13 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Icon } from 'mastodon/components/icon';
const filename = url => url.split('/').pop().split('#')[0].split('?')[0];

View file

@ -1,9 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
import { PureComponent } from 'react';
import { assetHost } from 'mastodon/utils/config';
export default class AutosuggestEmoji extends React.PureComponent {
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
export default class AutosuggestEmoji extends PureComponent {
static propTypes = {
emoji: PropTypes.object.isRequired,

View file

@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ShortNumber from 'mastodon/components/short_number';
import { FormattedMessage } from 'react-intl';
export default class AutosuggestHashtag extends React.PureComponent {
static propTypes = {
tag: PropTypes.shape({
name: PropTypes.string.isRequired,
url: PropTypes.string,
history: PropTypes.array,
}).isRequired,
};
render() {
const { tag } = this.props;
const weeklyUses = tag.history && (
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
);
return (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<FormattedMessage
id='autosuggest_hashtag.per_week'
defaultMessage='{count} per week'
values={{ count: weeklyUses }}
/>
</div>
)}
</div>
);
}
}

View file

@ -0,0 +1,42 @@
import { FormattedMessage } from 'react-intl';
import { ShortNumber } from 'mastodon/components/short_number';
interface Props {
tag: {
name: string;
url?: string;
history?: {
uses: number;
accounts: string;
day: string;
}[];
following?: boolean;
type: 'hashtag';
};
}
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
const weeklyUses = tag.history && (
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
);
return (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<FormattedMessage
id='autosuggest_hashtag.per_week'
defaultMessage='{count} per week'
values={{ count: weeklyUses }}
/>
</div>
)}
</div>
);
};

View file

@ -1,12 +1,15 @@
import React from 'react';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestHashtag from './autosuggest_hashtag';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;
@ -51,7 +54,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
searchTokens: PropTypes.arrayOf(PropTypes.string),
maxLength: PropTypes.number,
lang: PropTypes.string,
spellCheck: PropTypes.string,
spellCheck: PropTypes.bool,
};
static defaultProps = {
@ -154,7 +157,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
this.input.focus();
};
componentWillReceiveProps (nextProps) {
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
@ -180,7 +183,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
}
return (
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
<div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
{inner}
</div>
);

View file

@ -1,13 +1,17 @@
import React from 'react';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestHashtag from './autosuggest_hashtag';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
@ -153,7 +157,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
this.textarea.focus();
};
componentWillReceiveProps (nextProps) {
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
@ -186,7 +190,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}
return (
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
<div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
{inner}
</div>
);

View file

@ -1,62 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import classNames from 'classnames';
export default class Avatar extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
size: PropTypes.number.isRequired,
style: PropTypes.object,
inline: PropTypes.bool,
animate: PropTypes.bool,
};
static defaultProps = {
animate: autoPlayGif,
size: 20,
inline: false,
};
state = {
hovering: false,
};
handleMouseEnter = () => {
if (this.props.animate) return;
this.setState({ hovering: true });
};
handleMouseLeave = () => {
if (this.props.animate) return;
this.setState({ hovering: false });
};
render () {
const { account, size, animate, inline } = this.props;
const { hovering } = this.state;
const style = {
...this.props.style,
width: `${size}px`,
height: `${size}px`,
};
let src;
if (hovering || animate) {
src = account?.get('avatar');
} else {
src = account?.get('avatar_static');
}
return (
<div className={classNames('account__avatar', { 'account__avatar-inline': inline })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={style}>
{src && <img src={src} alt={account?.get('acct')} />}
</div>
);
}
}

View file

@ -0,0 +1,47 @@
import classNames from 'classnames';
import { useHovering } from '../../hooks/useHovering';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';
interface Props {
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
size: number;
style?: React.CSSProperties;
inline?: boolean;
animate?: boolean;
}
export const Avatar: React.FC<Props> = ({
account,
animate = autoPlayGif,
size = 20,
inline = false,
style: styleFromParent,
}) => {
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
const style = {
...styleFromParent,
width: `${size}px`,
height: `${size}px`,
};
const src =
hovering || animate
? account?.get('avatar')
: account?.get('avatar_static');
return (
<div
className={classNames('account__avatar', {
'account__avatar-inline': inline,
})}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={style}
>
{src && <img src={src} alt={account?.get('acct')} />}
</div>
);
};

View file

@ -1,10 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import Avatar from './avatar';
import { PureComponent } from 'react';
export default class AvatarComposite extends React.PureComponent {
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import { Avatar } from './avatar';
export default class AvatarComposite extends PureComponent {
static propTypes = {
accounts: ImmutablePropTypes.list.isRequired,

View file

@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import Avatar from './avatar';
export default class AvatarOverlay extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
size: PropTypes.number,
baseSize: PropTypes.number,
overlaySize: PropTypes.number,
};
static defaultProps = {
animate: autoPlayGif,
size: 46,
baseSize: 36,
overlaySize: 24,
};
state = {
hovering: false,
};
handleMouseEnter = () => {
if (this.props.animate) return;
this.setState({ hovering: true });
};
handleMouseLeave = () => {
if (this.props.animate) return;
this.setState({ hovering: false });
};
render() {
const { account, friend, animate, size, baseSize, overlaySize } = this.props;
const { hovering } = this.state;
return (
<div className='account__avatar-overlay' style={{ width: size, height: size }}>
<div className='account__avatar-overlay-base'><Avatar animate={hovering || animate} account={account} size={baseSize} /></div>
<div className='account__avatar-overlay-overlay'><Avatar animate={hovering || animate} account={friend} size={overlaySize} /></div>
</div>
);
}
}

View file

@ -0,0 +1,54 @@
import { useHovering } from '../../hooks/useHovering';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';
interface Props {
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
size?: number;
baseSize?: number;
overlaySize?: number;
}
export const AvatarOverlay: React.FC<Props> = ({
account,
friend,
size = 46,
baseSize = 36,
overlaySize = 24,
}) => {
const { hovering, handleMouseEnter, handleMouseLeave } =
useHovering(autoPlayGif);
const accountSrc = hovering
? account?.get('avatar')
: account?.get('avatar_static');
const friendSrc = hovering
? friend?.get('avatar')
: friend?.get('avatar_static');
return (
<div
className='account__avatar-overlay'
style={{ width: size, height: size }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className='account__avatar-overlay-base'>
<div
className='account__avatar'
style={{ width: `${baseSize}px`, height: `${baseSize}px` }}
>
{accountSrc && <img src={accountSrc} alt={account?.get('acct')} />}
</div>
</div>
<div className='account__avatar-overlay-overlay'>
<div
className='account__avatar'
style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }}
>
{friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,34 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { ReactComponent as GroupsIcon } from '@material-design-icons/svg/outlined/group.svg';
import { ReactComponent as PersonIcon } from '@material-design-icons/svg/outlined/person.svg';
import { ReactComponent as SmartToyIcon } from '@material-design-icons/svg/outlined/smart_toy.svg';
export const Badge = ({ icon, label, domain }) => (
<div className='account-role'>
{icon}
{label}
{domain && <span className='account-role__domain'>{domain}</span>}
</div>
);
Badge.propTypes = {
icon: PropTypes.node,
label: PropTypes.node,
domain: PropTypes.node,
};
Badge.defaultProps = {
icon: <PersonIcon />,
};
export const GroupBadge = () => (
<Badge icon={<GroupsIcon />} label={<FormattedMessage id='account.badges.group' defaultMessage='Group' />} />
);
export const AutomatedBadge = () => (
<Badge icon={<SmartToyIcon />} label={<FormattedMessage id='account.badges.bot' defaultMessage='Automated' />} />
);

View file

@ -1,65 +0,0 @@
// @ts-check
import { decode } from 'blurhash';
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
/**
* @typedef BlurhashPropsBase
* @property {string?} hash Hash to render
* @property {number} width
* Width of the blurred region in pixels. Defaults to 32
* @property {number} [height]
* Height of the blurred region in pixels. Defaults to width
* @property {boolean} [dummy]
* Whether dummy mode is enabled. If enabled, nothing is rendered
* and canvas left untouched
*/
/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
/**
* Component that is used to render blurred of blurhash string
*
* @param {BlurhashProps} param1 Props of the component
* @returns Canvas which will render blurred region element to embed
*/
function Blurhash({
hash,
width = 32,
height = width,
dummy = false,
...canvasProps
}) {
const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef());
useEffect(() => {
const { current: canvas } = canvasRef;
canvas.width = canvas.width; // resets canvas
if (dummy || !hash) return;
try {
const pixels = decode(hash, width, height);
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, width, height);
ctx.putImageData(imageData, 0, 0);
} catch (err) {
console.error('Blurhash decoding failure', { err, hash });
}
}, [dummy, hash, width, height]);
return (
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
);
}
Blurhash.propTypes = {
hash: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
dummy: PropTypes.bool,
};
export default React.memo(Blurhash);

View file

@ -0,0 +1,48 @@
import { memo, useRef, useEffect } from 'react';
import { decode } from 'blurhash';
interface Props extends React.HTMLAttributes<HTMLCanvasElement> {
hash: string;
width?: number;
height?: number;
dummy?: boolean; // Whether dummy mode is enabled. If enabled, nothing is rendered and canvas left untouched
children?: never;
}
const Blurhash: React.FC<Props> = ({
hash,
width = 32,
height = width,
dummy = false,
...canvasProps
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const canvas = canvasRef.current!;
// eslint-disable-next-line no-self-assign
canvas.width = canvas.width; // resets canvas
if (dummy || !hash) return;
try {
const pixels = decode(hash, width, height);
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, width, height);
ctx?.putImageData(imageData, 0, 0);
} catch (err) {
console.error('Blurhash decoding failure', { err, hash });
}
}, [dummy, hash, width, height]);
return (
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
);
};
const MemoizedBlurhash = memo(Blurhash);
export { MemoizedBlurhash as Blurhash };

View file

@ -1,8 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import classNames from 'classnames';
export default class Button extends React.PureComponent {
export default class Button extends PureComponent {
static propTypes = {
text: PropTypes.node,

View file

@ -1,9 +0,0 @@
import React from 'react';
const Check = () => (
<svg width='14' height='11' viewBox='0 0 14 11'>
<path d='M11.264 0L5.26 6.004 2.103 2.847 0 4.95l5.26 5.26 8.108-8.107L11.264 0' fill='currentColor' fillRule='evenodd' />
</svg>
);
export default Check;

View file

@ -0,0 +1,13 @@
export const Check: React.FC = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 20 20'
fill='currentColor'
>
<path
fillRule='evenodd'
d='M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'
clipRule='evenodd'
/>
</svg>
);

View file

@ -0,0 +1,27 @@
interface Props {
size: number;
strokeWidth: number;
}
export const CircularProgress: React.FC<Props> = ({ size, strokeWidth }) => {
const viewBox = `0 0 ${size} ${size}`;
const radius = (size - strokeWidth) / 2;
return (
<svg
width={size}
height={size}
viewBox={viewBox}
className='circular-progress'
role='progressbar'
>
<circle
fill='none'
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={`${strokeWidth}px`}
/>
</svg>
);
};

View file

@ -1,9 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { supportsPassiveEvents } from 'detect-passive-events';
import { scrollTop } from '../scroll';
export default class Column extends React.PureComponent {
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
export default class Column extends PureComponent {
static propTypes = {
children: PropTypes.node,
@ -35,17 +39,17 @@ export default class Column extends React.PureComponent {
componentDidMount () {
if (this.props.bindToDocument) {
document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
document.addEventListener('wheel', this.handleWheel, listenerOptions);
} else {
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
this.node.addEventListener('wheel', this.handleWheel, listenerOptions);
}
}
componentWillUnmount () {
if (this.props.bindToDocument) {
document.removeEventListener('wheel', this.handleWheel);
document.removeEventListener('wheel', this.handleWheel, listenerOptions);
} else {
this.node.removeEventListener('wheel', this.handleWheel);
this.node.removeEventListener('wheel', this.handleWheel, listenerOptions);
}
}

View file

@ -1,10 +1,12 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon';
import { PureComponent } from 'react';
import { createPortal } from 'react-dom';
export default class ColumnBackButton extends React.PureComponent {
import { FormattedMessage } from 'react-intl';
import { Icon } from 'mastodon/components/icon';
export default class ColumnBackButton extends PureComponent {
static contextTypes = {
router: PropTypes.object,
@ -12,13 +14,19 @@ export default class ColumnBackButton extends React.PureComponent {
static propTypes = {
multiColumn: PropTypes.bool,
onClick: PropTypes.func,
};
handleClick = () => {
if (window.history && window.history.state) {
this.context.router.history.goBack();
const { router } = this.context;
const { onClick } = this.props;
if (onClick) {
onClick();
} else if (router.history.location?.state?.fromMastodon) {
router.history.goBack();
} else {
this.context.router.history.push('/');
router.history.push('/');
}
};

View file

@ -1,14 +1,15 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Icon } from 'mastodon/components/icon';
import ColumnBackButton from './column_back_button';
import Icon from 'mastodon/components/icon';
export default class ColumnBackButtonSlim extends ColumnBackButton {
render () {
return (
<div className='column-back-button--slim'>
<div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
<div role='button' tabIndex={0} onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</div>

View file

@ -1,9 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import Icon from 'mastodon/components/icon';
import classNames from 'classnames';
import { Icon } from 'mastodon/components/icon';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
@ -12,8 +15,7 @@ const messages = defineMessages({
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
});
export default @injectIntl
class ColumnHeader extends React.PureComponent {
class ColumnHeader extends PureComponent {
static contextTypes = {
router: PropTypes.object,
@ -61,10 +63,12 @@ class ColumnHeader extends React.PureComponent {
};
handleBackClick = () => {
if (window.history && window.history.state) {
this.context.router.history.goBack();
const { router } = this.context;
if (router.history.location?.state?.fromMastodon) {
router.history.goBack();
} else {
this.context.router.history.push('/');
router.history.push('/');
}
};
@ -81,6 +85,7 @@ class ColumnHeader extends React.PureComponent {
};
render () {
const { router } = this.context;
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
const { collapsed, animating } = this.state;
@ -124,7 +129,7 @@ class ColumnHeader extends React.PureComponent {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
}
if (!pinned && (multiColumn || showBackButton)) {
if (!pinned && ((multiColumn && router.history.location?.state?.fromMastodon) || showBackButton)) {
backButton = (
<button onClick={this.handleBackClick} className='column-header__back-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
@ -209,3 +214,5 @@ class ColumnHeader extends React.PureComponent {
}
}
export default injectIntl(ColumnHeader);

View file

@ -1,62 +0,0 @@
// @ts-check
import React from 'react';
import { FormattedMessage } from 'react-intl';
/**
* Returns custom renderer for one of the common counter types
*
* @param {"statuses" | "following" | "followers"} counterType
* Type of the counter
* @param {boolean} isBold Whether display number must be displayed in bold
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
* Renderer function
* @throws If counterType is not covered by this function
*/
export function counterRenderer(counterType, isBold = true) {
/**
* @type {(displayNumber: JSX.Element) => JSX.Element}
*/
const renderCounter = isBold
? (displayNumber) => <strong>{displayNumber}</strong>
: (displayNumber) => displayNumber;
switch (counterType) {
case 'statuses': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'following': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'followers': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
}
}

View file

@ -0,0 +1,45 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
export const StatusesCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
export const FollowingCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
export const FollowersCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);

View file

@ -1,51 +0,0 @@
import React from 'react';
import IconButton from './icon_button';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import { bannerSettings } from 'mastodon/settings';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
export default @injectIntl
class DismissableBanner extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
children: PropTypes.node,
intl: PropTypes.object.isRequired,
};
state = {
visible: !bannerSettings.get(this.props.id),
};
handleDismiss = () => {
const { id } = this.props;
this.setState({ visible: false }, () => bannerSettings.set(id, true));
};
render () {
const { visible } = this.state;
if (!visible) {
return null;
}
const { children, intl } = this.props;
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>
{children}
</div>
<div className='dismissable-banner__action'>
<IconButton icon='times' title={intl.formatMessage(messages.dismiss)} onClick={this.handleDismiss} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,47 @@
import type { PropsWithChildren } from 'react';
import { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { bannerSettings } from 'mastodon/settings';
import { IconButton } from './icon_button';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
interface Props {
id: string;
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const [visible, setVisible] = useState(!bannerSettings.get(id));
const intl = useIntl();
const handleDismiss = useCallback(() => {
setVisible(false);
bannerSettings.set(id, true);
}, [id]);
if (!visible) {
return null;
}
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>{children}</div>
<div className='dismissable-banner__action'>
<IconButton
icon='times'
title={intl.formatMessage(messages.dismiss)}
onClick={handleDismiss}
/>
</div>
</div>
);
};

View file

@ -1,79 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { autoPlayGif } from 'mastodon/initial_state';
import Skeleton from 'mastodon/components/skeleton';
export default class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
others: ImmutablePropTypes.list,
localDomain: PropTypes.string,
};
handleMouseEnter = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
};
handleMouseLeave = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
};
render () {
const { others, localDomain } = this.props;
let displayName, suffix, account;
if (others && others.size > 1) {
displayName = others.take(2).map(a => <bdi key={a.get('id')}><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi>).reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else if ((others && others.size > 0) || this.props.account) {
if (others && others.size > 0) {
account = others.first();
} else {
account = this.props.account;
}
let acct = account.get('acct');
if (acct.indexOf('@') === -1 && localDomain) {
acct = `${acct}@${localDomain}`;
}
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
suffix = <span className='display-name__account'>@{acct}</span>;
} else {
displayName = <bdi><strong className='display-name__html'><Skeleton width='10ch' /></strong></bdi>;
suffix = <span className='display-name__account'><Skeleton width='7ch' /></span>;
}
return (
<span className='display-name' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
{displayName} {suffix}
</span>
);
}
}

View file

@ -0,0 +1,121 @@
import React from 'react';
import type { List } from 'immutable';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';
import { Skeleton } from './skeleton';
interface Props {
account?: Account;
others?: List<Account>;
localDomain?: string;
}
export class DisplayName extends React.PureComponent<Props> {
handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({
currentTarget,
}) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');
emojis.forEach((emoji) => {
const originalSrc = emoji.getAttribute('data-original');
if (originalSrc != null) emoji.src = originalSrc;
});
};
handleMouseLeave: React.ReactEventHandler<HTMLSpanElement> = ({
currentTarget,
}) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');
emojis.forEach((emoji) => {
const staticSrc = emoji.getAttribute('data-static');
if (staticSrc != null) emoji.src = staticSrc;
});
};
render() {
const { others, localDomain } = this.props;
let displayName: React.ReactNode,
suffix: React.ReactNode,
account: Account | undefined;
if (others && others.size > 0) {
account = others.first();
} else if (this.props.account) {
account = this.props.account;
}
if (others && others.size > 1) {
displayName = others
.take(2)
.map((a) => (
<bdi key={a.get('id')}>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
/>
</bdi>
))
.reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else if (account) {
let acct = account.get('acct');
if (!acct.includes('@') && localDomain) {
acct = `${acct}@${localDomain}`;
}
displayName = (
<bdi>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{
__html: account.get('display_name_html'),
}}
/>
</bdi>
);
suffix = <span className='display-name__account'>@{acct}</span>;
} else {
displayName = (
<bdi>
<strong className='display-name__html'>
<Skeleton width='10ch' />
</strong>
</bdi>
);
suffix = (
<span className='display-name__account'>
<Skeleton width='7ch' />
</span>
);
}
return (
<span
className='display-name'
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
>
{displayName} {suffix}
</span>
);
}
}

View file

@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
});
export default @injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
domain: PropTypes.string,
onUnblockDomain: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleDomainUnblock = () => {
this.props.onUnblockDomain(this.props.domain);
};
render () {
const { domain, intl } = this.props;
return (
<div className='domain'>
<div className='domain__wrapper'>
<span className='domain__domain-name'>
<strong>{domain}</strong>
</span>
<div className='domain__buttons'>
<IconButton active icon='unlock' title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} />
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,44 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { IconButton } from './icon_button';
const messages = defineMessages({
unblockDomain: {
id: 'account.unblock_domain',
defaultMessage: 'Unblock domain {domain}',
},
});
interface Props {
domain: string;
onUnblockDomain: (domain: string) => void;
}
export const Domain: React.FC<Props> = ({ domain, onUnblockDomain }) => {
const intl = useIntl();
const handleDomainUnblock = useCallback(() => {
onUnblockDomain(domain);
}, [domain, onUnblockDomain]);
return (
<div className='domain'>
<div className='domain__wrapper'>
<span className='domain__domain-name'>
<strong>{domain}</strong>
</span>
<div className='domain__buttons'>
<IconButton
active
icon='unlock'
title={intl.formatMessage(messages.unblockDomain, { domain })}
onClick={handleDomainUnblock}
/>
</div>
</div>
</div>
);
};

View file

@ -1,16 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button';
import Overlay from 'react-overlays/Overlay';
import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames';
import { CircularProgress } from 'mastodon/components/loading_indicator';
import { PureComponent, cloneElement, Children } from 'react';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import { CircularProgress } from "./circular_progress";
import { IconButton } from './icon_button';
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
let id = 0;
class DropdownMenu extends React.PureComponent {
class DropdownMenu extends PureComponent {
static contextTypes = {
router: PropTypes.object,
@ -35,12 +39,13 @@ class DropdownMenu extends React.PureComponent {
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
e.stopPropagation();
}
};
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('keydown', this.handleKeyDown, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
@ -49,8 +54,8 @@ class DropdownMenu extends React.PureComponent {
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('keydown', this.handleKeyDown, false);
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
@ -115,11 +120,11 @@ class DropdownMenu extends React.PureComponent {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href = '#', target = '_blank', method } = option;
const { text, href = '#', target = '_blank', method, dangerous } = option;
return (
<li className='dropdown-menu__item' key={`${text}-${i}`}>
<a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
<a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
{text}
</a>
</li>
@ -154,7 +159,7 @@ class DropdownMenu extends React.PureComponent {
}
export default class Dropdown extends React.PureComponent {
export default class Dropdown extends PureComponent {
static contextTypes = {
router: PropTypes.object,
@ -285,14 +290,14 @@ export default class Dropdown extends React.PureComponent {
const open = this.state.id === openDropdownId;
const button = children ? React.cloneElement(React.Children.only(children), {
const button = children ? cloneElement(Children.only(children), {
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown,
onKeyPress: this.handleKeyPress,
}) : (
<IconButton
icon={icon}
icon={!open ? icon : 'close'}
title={title}
active={open}
disabled={disabled}
@ -305,7 +310,7 @@ export default class Dropdown extends React.PureComponent {
);
return (
<React.Fragment>
<>
<span ref={this.setTargetRef}>
{button}
</span>
@ -328,7 +333,7 @@ export default class Dropdown extends React.PureComponent {
</div>
)}
</Overlay>
</React.Fragment>
</>
);
}

View file

@ -1,4 +1,5 @@
import { connect } from 'react-redux';
import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_menu';
import { fetchHistory } from 'mastodon/actions/history';
import DropdownMenu from 'mastodon/components/dropdown_menu';

View file

@ -1,24 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import Icon from 'mastodon/components/icon';
import DropdownMenu from './containers/dropdown_menu_container';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
import { Icon } from 'mastodon/components/icon';
import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import DropdownMenu from './containers/dropdown_menu_container';
const mapDispatchToProps = (dispatch, { statusId }) => ({
onItemClick (index) {
dispatch(openModal('COMPARE_HISTORY', { index, statusId }));
dispatch(openModal({
modalType: 'COMPARE_HISTORY',
modalProps: { index, statusId },
}));
},
});
export default @connect(null, mapDispatchToProps)
@injectIntl
class EditedTimestamp extends React.PureComponent {
class EditedTimestamp extends PureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
@ -34,7 +39,7 @@ class EditedTimestamp extends React.PureComponent {
renderHeader = items => {
return (
<FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {{count} time} other {{count} times}}' values={{ count: items.size - 1 }} />
<FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {# time} other {# times}}' values={{ count: items.size - 1 }} />
);
};
@ -68,3 +73,5 @@ class EditedTimestamp extends React.PureComponent {
}
}
export default connect(null, mapDispatchToProps)(injectIntl(EditedTimestamp));

View file

@ -0,0 +1,33 @@
import React from 'react';
import classNames from 'classnames';
import { DisplayName } from 'mastodon/components/display_name';
import { Skeleton } from 'mastodon/components/skeleton';
interface Props {
size?: number;
minimal?: boolean;
}
export const EmptyAccount: React.FC<Props> = ({
size = 46,
minimal = false,
}) => {
return (
<div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'>
<Skeleton width={size} height={size} />
</div>
<div>
<DisplayName />
<Skeleton width='7ch' />
</div>
</div>
</div>
</div>
);
};

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