Skip to content
Draft
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
4 changes: 3 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ mod irc;
mod log;
mod providers;
mod server;
mod util;

const CLIENT_ID: &str = "kimne78kx3ncx6brgo4mv6wki5h1ko";

Expand Down Expand Up @@ -194,6 +195,7 @@ fn get_handler() -> impl Fn(Invoke) -> bool {
providers::fetch_recent_messages,
providers::seventv::connect_seventv,
providers::seventv::send_presence,
server::start_server
server::start_server,
util::extract_seo,
]
}
49 changes: 49 additions & 0 deletions src-tauri/src/util.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use std::sync::LazyLock;

use regex::Regex;
use serde::Serialize;

use crate::error::Error;

#[derive(Serialize)]
pub struct Seo {
title: Option<String>,
description: Option<String>,
image: Option<String>,
}

static TITLE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"<title[^>]*>([^<]+)</title>"#).unwrap());
static DESC_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"<meta[^>]*name="description"[^>]*content="([^"]+)""#).unwrap());

static OG_TITLE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"<meta[^>]*property="og:title"[^>]*content="([^"]+)""#).unwrap());
static OG_DESC_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"<meta[^>]*property="og:description"[^>]*content="([^"]+)""#).unwrap()
});
static OG_IMAGE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"<meta[^>]*property="og:image"[^>]*content="([^"]+)""#).unwrap());

#[tauri::command]
pub async fn extract_seo(url: String) -> Result<Option<Seo>, Error> {
let res = reqwest::get(url).await?;
let body = res.text().await?;

Ok(Some(Seo {
title: TITLE_RE
.captures(&body)
.or_else(|| OG_TITLE_RE.captures(&body))
.and_then(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.or(None),
description: DESC_RE
.captures(&body)
.or_else(|| OG_DESC_RE.captures(&body))
.and_then(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.or(None),
image: OG_IMAGE_RE
.captures(&body)
.and_then(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.or(None),
}))
}
70 changes: 70 additions & 0 deletions src/lib/components/message/Link.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import * as HoverCard from "$lib/components/ui/hover-card";
import type { LinkNode } from "$lib/message";

interface Props {
node: LinkNode;
preview: boolean;
}

interface Seo {
title: string | null;
description: string | null;
image: string | null;
}

const { node, preview }: Props = $props();

function extractSeo() {
return invoke<Seo | null>("extract_seo", { url: node.data.url.toString() });
}
</script>

{#if preview}
{#await extractSeo() then seo}
{#if seo}
<HoverCard.Root openDelay={350}>
<HoverCard.Trigger>
{#snippet child({ props })}
{@render link(props)}
{/snippet}
</HoverCard.Trigger>

<HoverCard.Content class="bg-secondary">
<img
class="mb-2 aspect-[120/63] w-full rounded border object-cover"
src={seo.image}
alt={seo.title ?? ""}
/>

<p class="line-clamp-2 truncate text-sm font-semibold">
{seo.title}
</p>

<p class="text-muted-foreground text-xs">
{seo.description}
</p>
</HoverCard.Content>
</HoverCard.Root>
{:else}
{@render link()}
{/if}
{/await}
{:else}
{@render link()}
{/if}

{#snippet link(props: Record<string, unknown> = {})}
<svelte:element
this={node.marked ? "mark" : "span"}
{...props}
class={["wrap-anywhere underline hover:cursor-pointer", !node.marked && "text-twitch-link"]}
role="link"
tabindex="-1"
onclick={() => openUrl(node.data.url.toString())}
>
{node.value}
</svelte:element>
{/snippet}
15 changes: 2 additions & 13 deletions src/lib/components/message/Message.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
</script>

<script lang="ts">
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LinkNode, MentionNode, UserMessage } from "$lib/message";
import { settings } from "$lib/settings";
import { app } from "$lib/state.svelte";
Expand All @@ -15,6 +14,7 @@
import Timestamp from "../Timestamp.svelte";
import Tooltip from "../ui/Tooltip.svelte";
import Embed from "./Embed.svelte";
import Link from "./Link.svelte";

const { message, onEmbedLoad }: MessageProps = $props();

Expand Down Expand Up @@ -89,18 +89,7 @@ render properly without an extra space in between. -->
>
{#each message.nodes as node, i}
{#if node.type === "link"}
<svelte:element
this={node.marked ? "mark" : "span"}
class={[
"wrap-anywhere underline hover:cursor-pointer",
!node.marked && "text-twitch-link",
]}
role="link"
tabindex="-1"
onclick={() => openUrl(node.data.url.toString())}
>
{node.value}
</svelte:element>
<Link {node} preview={!canEmbed(node)} />
{:else if node.type === "mention"}
{#if !message.reply || (message.reply && i > 0)}
<svelte:element
Expand Down
29 changes: 29 additions & 0 deletions src/lib/components/ui/hover-card/hover-card-content.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script lang="ts">
import { LinkPreview as HoverCardPrimitive } from "bits-ui";
import { cn } from "$lib/util";

let {
ref = $bindable(null),
class: className,
align = "center",
sideOffset = 4,
portalProps,
...restProps
}: HoverCardPrimitive.ContentProps & {
portalProps?: HoverCardPrimitive.PortalProps;
} = $props();
</script>

<HoverCardPrimitive.Portal {...portalProps}>
<HoverCardPrimitive.Content
bind:ref
data-slot="hover-card-content"
{align}
{sideOffset}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 mt-3 w-64 rounded-md border p-4 shadow-md outline-hidden outline-none",
className,
)}
{...restProps}
/>
</HoverCardPrimitive.Portal>
7 changes: 7 additions & 0 deletions src/lib/components/ui/hover-card/hover-card-trigger.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script lang="ts">
import { LinkPreview as HoverCardPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: HoverCardPrimitive.TriggerProps = $props();
</script>

<HoverCardPrimitive.Trigger bind:ref data-slot="hover-card-trigger" {...restProps} />
14 changes: 14 additions & 0 deletions src/lib/components/ui/hover-card/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { LinkPreview as HoverCardPrimitive } from "bits-ui";
import Content from "./hover-card-content.svelte";
import Trigger from "./hover-card-trigger.svelte";

const Root = HoverCardPrimitive.Root;

export {
Content,
Root as HoverCard,
Content as HoverCardContent,
Trigger as HoverCardTrigger,
Root,
Trigger,
};