Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refs #327 Open user profile when click username in status #447

Merged
merged 3 commits into from
Feb 23, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion __tests__/unit/utils/statusParser.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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(`<html><head></head><body>
<div class="status-body">
<p><span><a href="https://social.mikutter.hachune.net/@h3_poteto">@<span id="user">h3_poteto</span></a></span> hogehoge</p>
</div>
</body>
</html>`).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(`<html><head></head><body>
<div class="status-body">
<p><span><a href="https://pleroma.io/users/h3poteto">@<span id="user">h3_poteto</span></a></span> hogehoge</p>
</div>
</body>
</html>`).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(`<html><head></head><body>
<div class="status-body">
<p><span><a id="status" href="https://https://fedibird.com/@h3poteto/103040884240752891">https://fedibird.com/@h3poteto/103040884240752891</a></span> hogehoge</p>
</div>
</body>
</html>`).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(`<html><head></head><body>
<div class="status-body">
<p><span><a id="status" href="https://pleroma.io/notice/9pqtJ78TcXAytY51Wa">https://pleroma.io/notice/9pqtJ78TcXAytY51Wa</a></span> hogehoge</p>
</div>
</body>
</html>`).window.document
const target = doc.getElementById('status')
it('should not find', () => {
expect(target).not.toBeNull()
const res = findAccount(target!, 'status-body')
expect(res).toBeNull()
})
})
})
})
7 changes: 7 additions & 0 deletions locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
73 changes: 70 additions & 3 deletions src/components/timelines/status/Status.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -29,9 +30,11 @@ type Props = {
} & HTMLAttributes<HTMLElement>

const Status: React.FC<Props> = props => {
const { t } = useTranslation()
const { client } = props
const [showReply, setShowReply] = useState<boolean>(false)
const [showEdit, setShowEdit] = useState<boolean>(false)
const toaster = useToaster()

const status = originalStatus(props.status)

Expand All @@ -49,7 +52,33 @@ const Status: React.FC<Props> = props => {
}
}, [showReply, showEdit])

const statusClicked: MouseEventHandler<HTMLDivElement> = e => {
const statusClicked: MouseEventHandler<HTMLDivElement> = async e => {
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
confirmToaster = toaster.push(
h3poteto marked this conversation as resolved.
Show resolved Hide resolved
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
}

const url = findLink(e.target as HTMLElement, 'status-body')
if (url) {
open(url)
Expand Down Expand Up @@ -196,4 +225,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<Entity.Account> = 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 (
<Notification type={type} header={title} closable>
<p>{message}</p>
<Button onClick={callback}>{button}</Button>
</Notification>
)
}

export default Status
12 changes: 7 additions & 5 deletions src/components/utils/alert.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Message } from 'rsuite'

const alert = (type: 'info' | 'success' | 'warning' | 'error', message: string) => (
<Message showIcon type={type} duration={5000}>
{message}
</Message>
)
function alert(type: 'info' | 'success' | 'warning' | 'error', message: string) {
return (
<Message showIcon type={type}>
{message}
</Message>
)
}

export default alert
79 changes: 79 additions & 0 deletions src/utils/statusParser.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Entity.Account>, 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
}