Skip to content
Merged
15 changes: 15 additions & 0 deletions src-tauri/src/api/streams.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use futures::TryStreamExt;
use tauri::State;
use tauri::async_runtime::Mutex;
use twitch_api::helix::clips::{Clip, get_clips};
use twitch_api::helix::streams::{CreatedStreamMarker, Stream};

use super::get_access_token;
Expand Down Expand Up @@ -50,3 +51,17 @@ pub async fn create_marker(

Ok(marker)
}

#[tauri::command]
pub async fn get_clip(
state: State<'_, Mutex<AppState>>,
id: String,
) -> Result<Option<Clip>, Error> {
let state = state.lock().await;
let token = get_access_token(&state)?;

let request = get_clips::GetClipsRequest::clip_ids(vec![id]);
let mut response = state.helix.req_get(request, token).await?;

Ok(response.data.pop())
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ fn get_handler() -> impl Fn(Invoke) -> bool {
api::streams::get_stream,
api::streams::get_streams,
api::streams::create_marker,
api::streams::get_clip,
api::users::get_user_from_id,
api::users::get_user_from_login,
api::users::get_user_emotes,
Expand Down
7 changes: 6 additions & 1 deletion src/lib/components/Chat.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,12 @@
{:else if message.autoMod}
<AutoMod {message} metadata={message.autoMod} />
{:else}
<UserMessage {message} />
<UserMessage
{message}
onEmbedLoad={() => {
if (!scrollingPaused) scrollToEnd();
}}
/>
{/if}

{@const next = app.joined?.messages.at(i + 1)}
Expand Down
138 changes: 138 additions & 0 deletions src/lib/components/message/Embed.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import dayjs from "dayjs";
import type { Emote, EmoteHost } from "$lib/seventv";
import type { Clip } from "$lib/twitch/api";

interface Props {
url: URL;
tld: ReturnType<typeof import("tldts").parse>;
onLoad?: () => void;
}

const { url, tld, onLoad }: Props = $props();

let blurred = $state(true);

async function fetchEmote() {
const response = await fetch(`https://7tv.io/v3/emotes/${url.pathname.split("/")[2]}`);
if (!response.ok) return;

onLoad?.();
return response.json() as Promise<Emote>;
}

async function fetchClip() {
let slug = url.pathname.split("/")[3];

if (tld.hostname === "clips.twitch.tv") {
slug = url.pathname.slice(1);
}

const clip = await invoke<Clip | null>("get_clip", { id: slug });

onLoad?.();
return clip;
}

function getSrcset(host: EmoteHost) {
return [
`https:${host.url}/1x.webp 1x`,
`https:${host.url}/2x.webp 2x`,
`https:${host.url}/3x.webp 3x`,
`https:${host.url}/4x.webp 4x`,
].join(", ");
}
</script>

<div class="w-full max-w-[400px]">
{#if tld.domain === "7tv.app"}
{#await fetchEmote() then emote}
<div class="bg-sidebar flex h-14 gap-2 overflow-hidden rounded-md border">
{#if emote}
<div class="relative h-full shrink-0">
<img
class="h-full w-auto"
srcset={getSrcset(emote.host)}
alt={emote.name}
decoding="async"
/>

{#if !emote.listed && blurred}
<button
class="absolute inset-0 backdrop-blur-lg"
aria-label="Click to view"
onclick={() => (blurred = false)}
>
<span class="iconify lucide--eye-off mt-1 size-5"></span>
</button>
{/if}
</div>

<div class="flex flex-col overflow-hidden py-1 pr-1">
<div class="flex items-center">
<span class="truncate text-sm font-medium" title={emote.name}>
{emote.name}
</span>

{#if !emote.listed}
<span class="ml-1 text-xs text-red-400">(unlisted)</span>
{/if}
</div>

<span class="text-muted-foreground text-xs">
by {emote.owner.display_name}
</span>
</div>
{/if}
</div>
{/await}
{:else if tld.hostname === "open.spotify.com"}
<div class="overflow-hidden rounded-xl">
<iframe
title="Spotify Web Player"
src="https://open.spotify.com/embed{url.pathname.replace(/\/intl-\w+\//, '/')}"
width="100%"
height="80"
allow="clipboard-write"
sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
></iframe>
</div>
{:else if tld.domain === "twitch.tv"}
{#await fetchClip() then clip}
{#if clip}
<div class="bg-sidebar flex h-18 gap-2 overflow-hidden rounded-md border">
<img src={clip.thumbnail_url} alt={clip.title} decoding="async" />

<div class="flex flex-col gap-0.5 overflow-hidden py-1 pr-1">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<span
class="text-twitch-link truncate text-sm font-medium hover:cursor-pointer"
role="link"
tabindex="-1"
onclick={() => openUrl(clip.url)}
>
{clip.title}
</span>

<span class="text-muted-foreground text-xs">
{dayjs(clip.created_at).format("MMMM D, YYYY")}
</span>

<div class="text-muted-foreground flex items-center gap-1 text-xs">
by {clip.creator_name}

<span class="text-foreground">&bullet;</span>

<div class="flex items-center">
<span class="iconify lucide--eye mr-1"></span>
{clip.view_count} views
</div>
</div>
</div>
</div>
{/if}
{/await}
{/if}
</div>
37 changes: 29 additions & 8 deletions src/lib/components/message/Message.svelte
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
<script lang="ts" module>
export interface MessageProps {
message: UserMessage;
onEmbedLoad?: () => void;
}
</script>

<script lang="ts">
import { openUrl } from "@tauri-apps/plugin-opener";
import type { Node, UserMessage } from "$lib/message";
import type { LinkNode, MentionNode, UserMessage } from "$lib/message";
import { settings } from "$lib/settings";
import { app } from "$lib/state.svelte";
import type { Badge } from "$lib/twitch/api";
import Emote from "../Emote.svelte";
import Timestamp from "../Timestamp.svelte";
import Tooltip from "../ui/Tooltip.svelte";
import Embed from "./Embed.svelte";

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

const badges = $state<Badge[]>([]);
const linkNodes = $derived(message.nodes.filter((n) => n.type === "link"));

for (const badge of message.badges) {
const chatBadge = app.joined?.badges.get(badge.name)?.[badge.version];
Expand All @@ -27,7 +36,7 @@
badges.push(message.author.badge);
}

function getMentionStyle(node: Extract<Node, { type: "mention" }>) {
function getMentionStyle(node: MentionNode) {
if (node.marked) return null;

switch (settings.state.chat.mentionStyle) {
Expand All @@ -39,6 +48,15 @@
return node.data.user?.style;
}
}

function canEmbed(node: LinkNode) {
return (
node.data.tld.domain === "7tv.app" ||
node.data.tld.hostname === "open.spotify.com" ||
node.data.tld.hostname === "clips.twitch.tv" ||
(node.data.tld.domain === "twitch.tv" && node.data.url.pathname.includes("/clip/"))
);
}
</script>

<Timestamp date={message.timestamp} />
Expand Down Expand Up @@ -120,14 +138,17 @@ render properly without an extra space in between. -->
{node.value}
</svelte:element>
{/if}

{#if i < message.nodes.length - 1}
<!-- eslint-disable-next-line svelte/no-useless-mustaches -->
<span>{" "}</span>
{/if}
{/each}
</p>

{#if linkNodes.some(canEmbed)}
<div class="mt-2 flex gap-2">
{#each linkNodes as node}
<Embed onLoad={onEmbedLoad} {...node.data} />
{/each}
</div>
{/if}

<style>
mark {
color: white;
Expand Down
8 changes: 4 additions & 4 deletions src/lib/components/message/UserMessage.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<script lang="ts">
import type { UserMessage } from "$lib/message";
import { settings } from "$lib/settings";
import type { HighlightType } from "$lib/settings";
import { app } from "$lib/state.svelte";
import type { User } from "$lib/user.svelte";
import QuickActions from "../QuickActions.svelte";
import Highlight from "./Highlight.svelte";
import Message from "./Message.svelte";
import type { MessageProps } from "./Message.svelte";

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

let hlType = $state<HighlightType>();
let info = $state<string>();
Expand Down Expand Up @@ -89,7 +89,7 @@

{#if message.highlighted}
<div class="bg-muted/50 my-0.5 border-l-4 p-2" style:border-color={app.joined?.user.color}>
<Message {message} />
<Message {message} {onEmbedLoad} />
</div>
{:else if highlights.enabled}
{#if hlType && highlights[hlType].enabled}
Expand Down Expand Up @@ -130,6 +130,6 @@
</div>
{/if}

<Message {message} />
<Message {message} {onEmbedLoad} />
</div>
{/snippet}
4 changes: 2 additions & 2 deletions src/lib/handlers/seventv/emote-set-update.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { SystemMessage } from "$lib/message";
import type { Emote } from "$lib/seventv";
import type { EmoteChange } from "$lib/seventv";
import { User } from "$lib/user.svelte";
import { defineHandler } from "../helper";

function reparse(emote: Emote) {
function reparse(emote: EmoteChange) {
let width = 28;
let height = 28;
const srcset: string[] = [];
Expand Down
19 changes: 10 additions & 9 deletions src/lib/message/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,34 @@ import type { User } from "$lib/user.svelte";
import { find } from "$lib/util";
import type { UserMessage } from "./user-message";

interface BaseNode {
export interface BaseNode {
start: number;
end: number;
value: string;
marked: boolean;
}

interface TextNode extends BaseNode {
export interface TextNode extends BaseNode {
type: "text";
data: string;
}

interface LinkNode extends BaseNode {
export interface LinkNode extends BaseNode {
type: "link";
data: {
url: URL;
tld: ReturnType<typeof parseTld>;
};
}

interface MentionNode extends BaseNode {
export interface MentionNode extends BaseNode {
type: "mention";
data: {
user: User | undefined;
};
}

interface CheerNode extends BaseNode {
export interface CheerNode extends BaseNode {
type: "cheer";
data: {
prefix: string;
Expand All @@ -42,7 +43,7 @@ interface CheerNode extends BaseNode {
};
}

interface EmoteNode extends BaseNode {
export interface EmoteNode extends BaseNode {
type: "emote";
data: {
layers: Emote[];
Expand Down Expand Up @@ -81,7 +82,7 @@ export function parse(message: UserMessage): Node[] {
};

const url = URL.parse(`https://${part.replace(/^https?:\/\/|\.$/i, "")}`);
const result = url ? parseTld(url.hostname) : null;
const tld = url ? parseTld(url.hostname) : null;

const cheermote = app.joined?.cheermotes.find((c) => {
const hasPrefix = part.toLowerCase().startsWith(c.prefix.toLowerCase());
Expand All @@ -93,11 +94,11 @@ export function parse(message: UserMessage): Node[] {
const ircEmote = ircEmotes.find((e) => e.code === part);
const emote = app.joined?.emotes.get(part);

if (url && result?.domain && result.isIcann) {
if (url && tld?.domain && tld.isIcann) {
nodes.push({
...base,
type: "link",
data: { url },
data: { url, tld },
});
} else if (/^@\w{4,24}$/.test(part)) {
const name = part.slice(1).toLowerCase();
Expand Down
Loading