diff --git a/__tests__/unit/utils/statusParser.ts b/__tests__/unit/utils/statusParser.ts index 3c6b2cca..c76009b3 100644 --- a/__tests__/unit/utils/statusParser.ts +++ b/__tests__/unit/utils/statusParser.ts @@ -1,5 +1,5 @@ import { JSDOM } from 'jsdom' -import { findLink } from 'src/utils/statusParser' +import { findLink, findAccount } from 'src/utils/statusParser' describe('findLink', () => { describe('Pleroma', () => { @@ -19,3 +19,71 @@ I released Whalebird version 2.4.1. In version 2.4.0, Whalebird supports streami }) }) }) + +describe('findAccount', () => { + describe('in Pleroma', () => { + describe('from Mastodon', () => { + const doc = new JSDOM(` +
+

@h3_poteto hogehoge

+
+ +`).window.document + const target = doc.getElementById('user') + it('should find', () => { + expect(target).not.toBeNull() + const res = findAccount(target!, 'status-body') + expect(res).not.toBeNull() + expect(res!.username).toEqual('@h3_poteto') + expect(res!.acct).toEqual('@h3_poteto@social.mikutter.hachune.net') + }) + }) + + describe('from Pleroma', () => { + const doc = new JSDOM(` +
+

@h3_poteto hogehoge

+
+ +`).window.document + const target = doc.getElementById('user') + it('should find', () => { + expect(target).not.toBeNull() + const res = findAccount(target!, 'status-body') + expect(res).not.toBeNull() + expect(res!.username).toEqual('@h3poteto') + expect(res!.acct).toEqual('@h3poteto@pleroma.io') + }) + }) + + describe('status link in Mastodon', () => { + const doc = new JSDOM(` +
+

https://fedibird.com/@h3poteto/103040884240752891 hogehoge

+
+ +`).window.document + const target = doc.getElementById('status') + it('should not find', () => { + expect(target).not.toBeNull() + const res = findAccount(target!, 'status-body') + expect(res).toBeNull() + }) + }) + + describe('status link in Pleroma', () => { + const doc = new JSDOM(` +
+

https://pleroma.io/notice/9pqtJ78TcXAytY51Wa hogehoge

+
+ +`).window.document + const target = doc.getElementById('status') + it('should not find', () => { + expect(target).not.toBeNull() + const res = findAccount(target!, 'status-body') + expect(res).toBeNull() + }) + }) + }) +}) diff --git a/locales/en/translation.json b/locales/en/translation.json index ba0b9b27..d0420adb 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -21,6 +21,13 @@ "validation_attachments_type": "You can attach only images or videos", "upload_error": "Failed to upload your file" }, + "dialog": { + "account_not_found": { + "title": "Account not found", + "message": "The account does not exist in the server, do you want to open the account in your browser?", + "button": "Open" + } + }, "walkthrough": { "navigator": { "servers": { diff --git a/src/components/timelines/notification/Notification.tsx b/src/components/timelines/notification/Notification.tsx index 1e509156..fb1dd203 100644 --- a/src/components/timelines/notification/Notification.tsx +++ b/src/components/timelines/notification/Notification.tsx @@ -36,6 +36,7 @@ const notification = (props: Props) => { case 'emoji_reaction': return ( void @@ -137,6 +139,7 @@ const actionText = (notification: Entity.Notification, setAccountDetail: (accoun } const Reaction: React.FC = props => { + const { t } = useTranslation() const status = props.notification.status const refresh = async () => { @@ -144,6 +147,43 @@ const Reaction: React.FC = props => { props.updateStatus(res.data) } + const statusClicked: MouseEventHandler = async e => { + // Check username + const parsedAccount = findAccount(e.target as HTMLElement, 'status-body') + if (parsedAccount) { + e.preventDefault() + + const account = await searchAccount(parsedAccount, status, props.client, props.server) + if (account) { + props.setAccountDetail(account) + } else { + let confirmToaster: any + /* eslint prefer-const: 0 */ + confirmToaster = toaster.push( + notification( + 'info', + t('dialog.account_not_found.title'), + t('dialog.account_not_found.message'), + t('dialog.account_not_found.button'), + () => { + open(parsedAccount.url) + toaster.remove(confirmToaster) + } + ), + { placement: 'topCenter', duration: 0 } + ) + } + return + } + + // Check link + const url = findLink(e.target as HTMLElement, 'status-body') + if (url) { + open(url) + e.preventDefault() + } + } + return (
{/** action **/} @@ -205,12 +245,42 @@ const Reaction: React.FC = props => { ) } -const statusClicked: MouseEventHandler = e => { - const url = findLink(e.target as HTMLElement, 'status-body') - if (url) { - open(url) - e.preventDefault() +async function searchAccount(account: ParsedAccount, status: Entity.Status, client: MegalodonInterface, server: Server) { + if (status.in_reply_to_account_id) { + const res = await client.getAccount(status.in_reply_to_account_id) + if (res.status === 200) { + const user = accountMatch([res.data], account, server.domain) + if (user) return user + } } + if (status.in_reply_to_id) { + const res = await client.getStatusContext(status.id) + if (res.status === 200) { + const accounts: Array = res.data.ancestors.map(s => s.account).concat(res.data.descendants.map(s => s.account)) + const user = accountMatch(accounts, account, server.domain) + if (user) return user + } + } + const res = await client.searchAccount(account.url, { resolve: true }) + if (res.data.length === 0) return null + const user = accountMatch(res.data, account, server.domain) + if (user) return user + return null +} + +function notification( + type: 'info' | 'success' | 'warning' | 'error', + title: string, + message: string, + button: string, + callback: () => void +) { + return ( + +

{message}

+ +
+ ) } export default Reaction diff --git a/src/components/timelines/status/Status.tsx b/src/components/timelines/status/Status.tsx index 653528bc..24d746fc 100644 --- a/src/components/timelines/status/Status.tsx +++ b/src/components/timelines/status/Status.tsx @@ -1,13 +1,14 @@ import { HTMLAttributes, MouseEventHandler, useEffect, useState } from 'react' import { Entity, MegalodonInterface } from 'megalodon' -import { FlexboxGrid, Avatar, Button } from 'rsuite' +import { FlexboxGrid, Avatar, Button, useToaster, Notification } from 'rsuite' import { Icon } from '@rsuite/icons' import { BsArrowRepeat, BsPin } from 'react-icons/bs' import { open } from '@tauri-apps/api/shell' +import { useTranslation } from 'react-i18next' import Time from 'src/components/utils/Time' import emojify from 'src/utils/emojify' import Attachments from './Attachments' -import { findLink } from 'src/utils/statusParser' +import { accountMatch, findAccount, findLink, ParsedAccount } from 'src/utils/statusParser' import Reply from 'src/components/compose/Status' import { Account } from 'src/entities/account' import { Server } from 'src/entities/server' @@ -29,9 +30,11 @@ type Props = { } & HTMLAttributes const Status: React.FC = props => { + const { t } = useTranslation() const { client } = props const [showReply, setShowReply] = useState(false) const [showEdit, setShowEdit] = useState(false) + const toaster = useToaster() const status = originalStatus(props.status) @@ -49,7 +52,36 @@ const Status: React.FC = props => { } }, [showReply, showEdit]) - const statusClicked: MouseEventHandler = e => { + const statusClicked: MouseEventHandler = async e => { + // Check username + const parsedAccount = findAccount(e.target as HTMLElement, 'status-body') + if (parsedAccount) { + e.preventDefault() + + const account = await searchAccount(parsedAccount, props.status, props.client, props.server) + if (account) { + props.setAccountDetail(account.id, props.server.id, props.account?.id) + } else { + let confirmToaster: any + /* eslint prefer-const: 0 */ + confirmToaster = toaster.push( + notification( + 'info', + t('dialog.account_not_found.title'), + t('dialog.account_not_found.message'), + t('dialog.account_not_found.button'), + () => { + open(parsedAccount.url) + toaster.remove(confirmToaster) + } + ), + { placement: 'topCenter', duration: 0 } + ) + } + return + } + + // Check link const url = findLink(e.target as HTMLElement, 'status-body') if (url) { open(url) @@ -196,4 +228,42 @@ const rebloggedHeader = (status: Entity.Status) => { } } +async function searchAccount(account: ParsedAccount, status: Entity.Status, client: MegalodonInterface, server: Server) { + if (status.in_reply_to_account_id) { + const res = await client.getAccount(status.in_reply_to_account_id) + if (res.status === 200) { + const user = accountMatch([res.data], account, server.domain) + if (user) return user + } + } + if (status.in_reply_to_id) { + const res = await client.getStatusContext(status.id) + if (res.status === 200) { + const accounts: Array = res.data.ancestors.map(s => s.account).concat(res.data.descendants.map(s => s.account)) + const user = accountMatch(accounts, account, server.domain) + if (user) return user + } + } + const res = await client.searchAccount(account.url, { resolve: true }) + if (res.data.length === 0) return null + const user = accountMatch(res.data, account, server.domain) + if (user) return user + return null +} + +function notification( + type: 'info' | 'success' | 'warning' | 'error', + title: string, + message: string, + button: string, + callback: () => void +) { + return ( + +

{message}

+ +
+ ) +} + export default Status diff --git a/src/components/utils/alert.tsx b/src/components/utils/alert.tsx index 78cea222..2c0b1358 100644 --- a/src/components/utils/alert.tsx +++ b/src/components/utils/alert.tsx @@ -1,9 +1,11 @@ import { Message } from 'rsuite' -const alert = (type: 'info' | 'success' | 'warning' | 'error', message: string) => ( - - {message} - -) +function alert(type: 'info' | 'success' | 'warning' | 'error', message: string) { + return ( + + {message} + + ) +} export default alert diff --git a/src/utils/statusParser.ts b/src/utils/statusParser.ts index f09ab642..33230f1e 100644 --- a/src/utils/statusParser.ts +++ b/src/utils/statusParser.ts @@ -1,3 +1,9 @@ +export type ParsedAccount = { + username: string + acct: string + url: string +} + export function findLink(target: HTMLElement | null, parentClassName: string): string | null { if (!target) { return null @@ -14,3 +20,76 @@ export function findLink(target: HTMLElement | null, parentClassName: string): s } return findLink(parent, parentClassName) } + +export function findAccount(target: HTMLElement | null, parentClassName: string): ParsedAccount | null { + if (!target) { + return null + } + + const targetClass = target.getAttribute('class') + const link = target as HTMLLinkElement + if (targetClass && targetClass.includes('u-url')) { + if (link.href && link.href.match(/^https:\/\/[a-zA-Z0-9-.]+\/users\/[a-zA-Z0-9-_.]+$/)) { + return parsePleromaAccount(link.href) + } else { + return parseMastodonAccount(link.href) + } + } + // In Pleroma, link does not have class. + // So we have to check URL. + if (link.href && link.href.match(/^https:\/\/[a-zA-Z0-9-.]+\/@[a-zA-Z0-9-_.]+$/)) { + return parseMastodonAccount(link.href) + } + // Toot URL of Pleroma does not contain @. + if (link.href && link.href.match(/^https:\/\/[a-zA-Z0-9-.]+\/users\/[a-zA-Z0-9-_.]+$/)) { + return parsePleromaAccount(link.href) + } + if (target.parentNode === undefined || target.parentNode === null) { + return null + } + const parent = target.parentNode as HTMLElement + if (parent.getAttribute('class') === parentClassName) { + return null + } + return findAccount(parent, parentClassName) +} + +export function parseMastodonAccount(accountURL: string): ParsedAccount | null { + const res = accountURL.match(/^https:\/\/([a-zA-Z0-9-.]+)\/(@[a-zA-Z0-9-_.]+)$/) + if (!res) { + return null + } + const domainName = res[1] + const accountName = res[2] + return { + username: accountName, + acct: `${accountName}@${domainName}`, + url: accountURL + } +} + +export function parsePleromaAccount(accountURL: string): ParsedAccount | null { + const res = accountURL.match(/^https:\/\/([a-zA-Z0-9-.]+)\/users\/([a-zA-Z0-9-_.]+)$/) + if (!res) { + return null + } + const domainName = res[1] + const accountName = res[2] + return { + username: `@${accountName}`, + acct: `@${accountName}@${domainName}`, + url: accountURL + } +} + +export function accountMatch(findAccounts: Array, parsedAccount: ParsedAccount, domain: string): Entity.Account | false { + const account = findAccounts.find(a => `@${a.acct}` === parsedAccount.acct) + if (account) return account + const pleromaUser = findAccounts.find(a => a.acct === parsedAccount.acct) + if (pleromaUser) return pleromaUser + const localUser = findAccounts.find(a => `@${a.username}@${domain}` === parsedAccount.acct) + if (localUser) return localUser + const user = findAccounts.find(a => a.url === parsedAccount.url) + if (!user) return false + return user +}