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(`
+
+
+`).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(`
+
+
+`).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(`
+
+
+`).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(`
+
+
+`).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
+}