diff --git a/renderer/components/detail/Profile.tsx b/renderer/components/detail/Profile.tsx index 314fd181..561e7e5a 100644 --- a/renderer/components/detail/Profile.tsx +++ b/renderer/components/detail/Profile.tsx @@ -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 @@ -55,6 +56,15 @@ export default function Profile(props: Props) { global.ipc.invoke('open-browser', url) } + const profileClicked: MouseEventHandler = async e => { + const url = findLink(e.target as HTMLElement, 'profile') + if (url) { + global.ipc.invoke('open-browser', url) + e.preventDefault() + e.stopPropagation() + } + } + return (
@@ -93,13 +103,13 @@ export default function Profile(props: Props) {
@{user.acct}
-
+
-
+
{user.fields.map((data, index) => (
{data.name}
diff --git a/renderer/components/timelines/status/Body.tsx b/renderer/components/timelines/status/Body.tsx index 50572bb3..63a7ad26 100644 --- a/renderer/components/timelines/status/Body.tsx +++ b/renderer/components/timelines/status/Body.tsx @@ -8,6 +8,7 @@ type Props = { status: Entity.Status spoilered: boolean setSpoilered: Dispatch> + onClick?: (e: any) => void } & HTMLAttributes export default function Body(props: Props) { @@ -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} /> )} diff --git a/renderer/components/timelines/status/Status.tsx b/renderer/components/timelines/status/Status.tsx index 2d0a5b37..75154a40 100644 --- a/renderer/components/timelines/status/Status.tsx +++ b/renderer/components/timelines/status/Status.tsx @@ -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 @@ -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 = async e => { + const url = findLink(e.target as HTMLElement, 'status-body') + if (url) { + global.ipc.invoke('open-browser', url) + e.preventDefault() + e.stopPropagation() + } + } + return (
{rebloggedHeader(props.status)} @@ -56,7 +66,9 @@ export default function Status(props: Props) {
- +
+ +
{!spoilered && ( <> {status.poll && } diff --git a/renderer/utils/statusParser.ts b/renderer/utils/statusParser.ts new file mode 100644 index 00000000..930d4aa3 --- /dev/null +++ b/renderer/utils/statusParser.ts @@ -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, 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 +}