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 #4653 Open link in browser #4681

Merged
merged 1 commit into from
Dec 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 13 additions & 3 deletions renderer/components/detail/Profile.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import emojify from '@/utils/emojify'
import { Avatar, Button, CustomFlowbiteTheme, Dropdown, Flowbite, Tabs } from 'flowbite-react'
import { Entity, MegalodonInterface } from 'megalodon'
import { useEffect, useState } from 'react'
import { MouseEventHandler, useEffect, useState } from 'react'
import { FaEllipsisVertical } from 'react-icons/fa6'
import { FormattedMessage, useIntl } from 'react-intl'
import Timeline from './profile/Timeline'
import Followings from './profile/Followings'
import Followers from './profile/Followers'
import { findLink } from '@/utils/statusParser'

type Props = {
client: MegalodonInterface
Expand Down Expand Up @@ -55,6 +56,15 @@ export default function Profile(props: Props) {
global.ipc.invoke('open-browser', url)
}

const profileClicked: MouseEventHandler<HTMLDivElement> = async e => {
const url = findLink(e.target as HTMLElement, 'profile')
if (url) {
global.ipc.invoke('open-browser', url)
e.preventDefault()
e.stopPropagation()
}
}

return (
<div style={{ height: 'calc(100% - 50px)' }} className="overflow-y-auto timeline-scrollable">
<Flowbite theme={{ theme: customTheme }}>
Expand Down Expand Up @@ -93,13 +103,13 @@ export default function Profile(props: Props) {
<div className="pt-4">
<div className="font-bold" dangerouslySetInnerHTML={{ __html: emojify(user.display_name, user.emojis) }} />
<div className="text-gray-500">@{user.acct}</div>
<div className="mt-4 raw-html">
<div className="mt-4 raw-html profile" onClick={profileClicked}>
<span
dangerouslySetInnerHTML={{ __html: emojify(user.note, user.emojis) }}
className="overflow-hidden break-all text-gray-800"
/>
</div>
<div className="bg-gray-100 overflow-hidden break-all raw-html mt-2">
<div className="bg-gray-100 overflow-hidden break-all raw-html mt-2 profile" onClick={profileClicked}>
{user.fields.map((data, index) => (
<dl key={index} className="px-4 py-2 border-gray-200 border-b">
<dt className="text-gray-500">{data.name}</dt>
Expand Down
2 changes: 2 additions & 0 deletions renderer/components/timelines/status/Body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type Props = {
status: Entity.Status
spoilered: boolean
setSpoilered: Dispatch<SetStateAction<boolean>>
onClick?: (e: any) => void
} & HTMLAttributes<HTMLElement>

export default function Body(props: Props) {
Expand Down Expand Up @@ -41,6 +42,7 @@ export default function Body(props: Props) {
className={`${props.className} raw-html`}
style={Object.assign({ wordWrap: 'break-word' }, props.style)}
dangerouslySetInnerHTML={{ __html: emojify(props.status.content, props.status.emojis) }}
onClick={props.onClick}
/>
)}
</>
Expand Down
16 changes: 14 additions & 2 deletions renderer/components/timelines/status/Status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import Poll from './Poll'
import { FormattedMessage } from 'react-intl'
import Actions from './Actions'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { MouseEventHandler, useState } from 'react'
import { findLink } from '@/utils/statusParser'

type Props = {
status: Entity.Status
Expand All @@ -36,6 +37,15 @@ export default function Status(props: Props) {
router.push({ query: { id: router.query.id, timeline: router.query.timeline, user_id: id, detail: true } })
}

const statusClicked: MouseEventHandler<HTMLDivElement> = async e => {
const url = findLink(e.target as HTMLElement, 'status-body')
if (url) {
global.ipc.invoke('open-browser', url)
e.preventDefault()
e.stopPropagation()
}
}

return (
<div className="border-b mr-2 py-1">
{rebloggedHeader(props.status)}
Expand All @@ -56,7 +66,9 @@ export default function Status(props: Props) {
<time dateTime={status.created_at}>{dayjs(status.created_at).format('YYYY-MM-DD HH:mm:ss')}</time>
</div>
</div>
<Body status={status} spoilered={spoilered} setSpoilered={setSpoilered} />
<div className="status-body">
<Body status={status} spoilered={spoilered} setSpoilered={setSpoilered} onClick={statusClicked} />
</div>
{!spoilered && (
<>
{status.poll && <Poll poll={status.poll} onRefresh={onRefresh} client={props.client} />}
Expand Down
129 changes: 129 additions & 0 deletions renderer/utils/statusParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Entity } from 'megalodon'

export type ParsedAccount = {
username: string
acct: string
url: string
}

export function findLink(target: HTMLElement | null, parentClassName: string): string | null {
if (!target) {
return null
}
if (target.localName === 'a') {
return (target as HTMLLinkElement).href
}
if (target.parentNode === undefined || target.parentNode === null) {
return null
}
const parent = target.parentNode as HTMLElement
if (parent.getAttribute && parent.getAttribute('class') === parentClassName) {
return null
}
return findLink(parent, parentClassName)
}

export function findTag(target: HTMLElement, parentClass = 'toot'): string | null {
if (!target || !target.getAttribute) {
return null
}
const targetClass = target.getAttribute('class')
if (targetClass && targetClass.includes('hashtag')) {
return parseTag((target as HTMLLinkElement).href)
}
// In Pleroma, link does not have class.
// So I have to check URL.
const link = target as HTMLLinkElement
if (link.href && link.href.match(/^https:\/\/[a-zA-Z0-9-.]+\/(tag|tags)\/.+/)) {
return parseTag(link.href)
}
if (target.parentNode === undefined || target.parentNode === null) {
return null
}
const parent = target.parentNode as HTMLElement
if (parent.getAttribute && parent.getAttribute('class') === parentClass) {
return null
}
return findTag(parent, parentClass)
}

function parseTag(tagURL: string): string | null {
const res = tagURL.match(/^https:\/\/([a-zA-Z0-9-.]+)\/(tag|tags)\/(.+)/)
if (!res) {
return null
}
return res[3]
}

export function findAccount(target: HTMLElement | null, parentClassName: string): ParsedAccount | null {
if (!target || !target.getAttribute) {
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 && 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
}