feat: imgproxy

This commit is contained in:
Kieran 2023-01-31 14:41:21 +00:00
parent 02800defd5
commit c16d5d19e5
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
8 changed files with 154 additions and 44 deletions

View File

@ -1,19 +1,24 @@
import "./Avatar.css"; import "./Avatar.css";
import Nostrich from "nostrich.webp"; import Nostrich from "nostrich.webp";
import { CSSProperties } from "react"; import { CSSProperties, useEffect, useState } from "react";
import type { UserMetadata } from "Nostr"; import type { UserMetadata } from "Nostr";
import { useSelector } from "react-redux"; import useImgProxy from "Feed/ImgProxy";
import { RootState } from "State/Store";
import { ApiHost } from "Const";
const Avatar = ({ user, ...rest }: { user?: UserMetadata, onClick?: () => void }) => { const Avatar = ({ user, ...rest }: { user?: UserMetadata, onClick?: () => void }) => {
const useImageProxy = useSelector((s: RootState) => s.login.preferences.useImageProxy); const [url, setUrl] = useState<string>(Nostrich);
const { proxy } = useImgProxy();
const avatarUrl = (user?.picture?.length ?? 0) === 0 ? Nostrich : useEffect(() => {
(useImageProxy ? `${ApiHost}/api/v1/imgproxy/${window.btoa(user!.picture!)}` : user?.picture) if (user?.picture) {
const backgroundImage = `url(${avatarUrl})` proxy(user.picture)
const domain = user?.nip05 && user.nip05.split('@')[1] .then(a => setUrl(a))
.catch(console.warn);
}
}, [user]);
const backgroundImage = `url(${url})`
const style = { '--img-url': backgroundImage } as CSSProperties const style = { '--img-url': backgroundImage } as CSSProperties
const domain = user?.nip05 && user.nip05.split('@')[1]
return ( return (
<div <div
{...rest} {...rest}

17
src/Element/ProxyImg.tsx Normal file
View File

@ -0,0 +1,17 @@
import useImgProxy from "Feed/ImgProxy";
import { useEffect, useState } from "react";
export const ProxyImg = ({ src, ...rest }: { src?: string }) => {
const [url, setUrl] = useState<string>();
const { proxy } = useImgProxy();
useEffect(() => {
if (src) {
proxy(src)
.then(a => setUrl(a))
.catch(console.warn);
}
}, [src]);
return <img src={url} {...rest} />
}

View File

@ -17,8 +17,9 @@ import TidalEmbed from "Element/TidalEmbed";
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { RootState } from 'State/Store'; import { RootState } from 'State/Store';
import { UserPreferences } from 'State/Login'; import { UserPreferences } from 'State/Login';
import SoundCloudEmbed from 'Element/SoundCloudEmded' import SoundCloudEmbed from 'Element/SoundCloudEmded'
import MixCloudEmbed from './MixCloudEmbed'; import MixCloudEmbed from 'Element/MixCloudEmbed';
import { ProxyImg } from 'Element/ProxyImg';
function transformHttpLink(a: string, pref: UserPreferences) { function transformHttpLink(a: string, pref: UserPreferences) {
try { try {
@ -40,7 +41,7 @@ function transformHttpLink(a: string, pref: UserPreferences) {
case "png": case "png":
case "bmp": case "bmp":
case "webp": { case "webp": {
return <img key={url.toString()} src={url.toString()} />; return <ProxyImg key={url.toString()} src={url.toString()} />;
} }
case "wav": case "wav":
case "mp3": case "mp3":
@ -81,9 +82,9 @@ function transformHttpLink(a: string, pref: UserPreferences) {
) )
} else if (tidalId) { } else if (tidalId) {
return <TidalEmbed link={a} /> return <TidalEmbed link={a} />
} else if (soundcloundId){ } else if (soundcloundId) {
return <SoundCloudEmbed link={a} /> return <SoundCloudEmbed link={a} />
} else if (mixcloudId){ } else if (mixcloudId) {
return <MixCloudEmbed link={a} /> return <MixCloudEmbed link={a} />
} else { } else {
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a> return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
@ -223,14 +224,14 @@ export default function Text({ content, tags, users }: TextProps) {
parent && parent &&
typeof index === 'number' && typeof index === 'number' &&
(node.type === 'link' || (node.type === 'link' ||
node.type === 'linkReference' || node.type === 'linkReference' ||
node.type === 'image' || node.type === 'image' ||
node.type === 'imageReference' || node.type === 'imageReference' ||
node.type === 'definition') node.type === 'definition')
) { ) {
node.type = 'text'; node.type = 'text';
node.value = content.slice(node.position.start.offset, node.position.end.offset).replace(/\)$/, ' )'); node.value = content.slice(node.position.start.offset, node.position.end.offset).replace(/\)$/, ' )');
return SKIP; return SKIP;
} }
}) })
}, [content]); }, [content]);

39
src/Feed/ImgProxy.ts Normal file
View File

@ -0,0 +1,39 @@
import * as secp from "@noble/secp256k1"
import * as base64 from "@protobufjs/base64"
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
export interface ImgProxySettings {
url: string,
key: string,
salt: string
}
export default function useImgProxy() {
const settings = useSelector((s: RootState) => s.login.preferences.imgProxyConfig);
const te = new TextEncoder();
function urlSafe(s: string) {
return s.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
async function signUrl(u: string) {
const result = await secp.utils.hmacSha256(
secp.utils.hexToBytes(settings!.key),
secp.utils.hexToBytes(settings!.salt),
te.encode(u));
return urlSafe(base64.encode(result, 0, result.byteLength));
}
return {
proxy: async (url: string, resize?: number) => {
if (!settings) return url;
const opt = resize ? `rs:fit:${resize}:${resize}` : "";
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes, 0, urlBytes.byteLength));
const path = `/${opt}/${urlEncoded}`;
const sig = await signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;
}
}
}

View File

@ -1,5 +1,5 @@
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { setPreferences, UserPreferences } from "State/Login"; import { InitState, setPreferences, UserPreferences } from "State/Login";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import "./Preferences.css"; import "./Preferences.css";
@ -16,7 +16,7 @@ const PreferencesPage = () => {
<div>Theme</div> <div>Theme</div>
</div> </div>
<div> <div>
<select value={perf.theme} onChange={e => dispatch(setPreferences({ ...perf, theme: e.target.value} as UserPreferences))}> <select value={perf.theme} onChange={e => dispatch(setPreferences({ ...perf, theme: e.target.value } as UserPreferences))}>
<option value="system">System (Default)</option> <option value="system">System (Default)</option>
<option value="light">Light</option> <option value="light">Light</option>
<option value="dark">Dark</option> <option value="dark">Dark</option>
@ -32,6 +32,43 @@ const PreferencesPage = () => {
<input type="checkbox" checked={perf.autoLoadMedia} onChange={e => dispatch(setPreferences({ ...perf, autoLoadMedia: e.target.checked }))} /> <input type="checkbox" checked={perf.autoLoadMedia} onChange={e => dispatch(setPreferences({ ...perf, autoLoadMedia: e.target.checked }))} />
</div> </div>
</div> </div>
<div className="card flex f-col">
<div className="flex w-max">
<div className="flex f-col f-grow">
<div>Image proxy service</div>
<small>Use imgproxy to compress images</small>
</div>
<div>
<input type="checkbox" checked={perf.imgProxyConfig !== null} onChange={e => dispatch(setPreferences({ ...perf, imgProxyConfig: e.target.checked ? InitState.preferences.imgProxyConfig : null }))} />
</div>
</div>
{perf.imgProxyConfig && (<div className="w-max mt10 form">
<div className="form-group">
<div>
Service Url
</div>
<div className="w-max">
<input type="text" value={perf.imgProxyConfig?.url} placeholder="Url.." onChange={e => dispatch(setPreferences({ ...perf, imgProxyConfig: { ...perf.imgProxyConfig!, url: e.target.value } }))} />
</div>
</div>
<div className="form-group">
<div>
Service Key
</div>
<div className="w-max">
<input type="password" value={perf.imgProxyConfig?.key} placeholder="Hex key.." onChange={e => dispatch(setPreferences({ ...perf, imgProxyConfig: { ...perf.imgProxyConfig!, key: e.target.value } }))} />
</div>
</div>
<div className="form-group">
<div>
Service Salt
</div>
<div className="w-max">
<input type="password" value={perf.imgProxyConfig?.salt} placeholder="Hex salt.." onChange={e => dispatch(setPreferences({ ...perf, imgProxyConfig: { ...perf.imgProxyConfig!, salt: e.target.value } }))} />
</div>
</div>
</div>)}
</div>
<div className="card flex"> <div className="card flex">
<div className="flex f-col f-grow"> <div className="flex f-col f-grow">
<div>Enable reactions</div> <div>Enable reactions</div>
@ -65,21 +102,12 @@ const PreferencesPage = () => {
<small>Pick which upload service you want to upload attachments to</small> <small>Pick which upload service you want to upload attachments to</small>
</div> </div>
<div> <div>
<select value={perf.fileUploader} onChange={e => dispatch(setPreferences({ ...perf, fileUploader: e.target.value} as UserPreferences))}> <select value={perf.fileUploader} onChange={e => dispatch(setPreferences({ ...perf, fileUploader: e.target.value } as UserPreferences))}>
<option value="void.cat">void.cat (Default)</option> <option value="void.cat">void.cat (Default)</option>
<option value="nostr.build">nostr.build</option> <option value="nostr.build">nostr.build</option>
</select> </select>
</div> </div>
</div> </div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Image proxy</div>
<small>Use the caching image proxy to load avatars</small>
</div>
<div>
<input type="checkbox" checked={perf.useImageProxy} onChange={e => dispatch(setPreferences({ ...perf, useImageProxy: e.target.checked }))} />
</div>
</div>
<div className="card flex"> <div className="card flex">
<div className="flex f-col f-grow"> <div className="flex f-col f-grow">
<div>Debug Menus</div> <div>Debug Menus</div>

View File

@ -102,7 +102,7 @@ export default function ProfileSettings() {
function editor() { function editor() {
return ( return (
<div className="editor"> <div className="editor form">
<div className="form-group"> <div className="form-group">
<div>Name:</div> <div>Name:</div>
<div> <div>
@ -115,7 +115,7 @@ export default function ProfileSettings() {
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} /> <input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</div> </div>
</div> </div>
<div className="form-group f-col"> <div className="form-group form-col">
<div>About:</div> <div>About:</div>
<div className="w-max"> <div className="w-max">
<textarea className="w-max" onChange={(e) => setAbout(e.target.value)} value={about}></textarea> <textarea className="w-max" onChange={(e) => setAbout(e.target.value)} value={about}></textarea>

View File

@ -4,6 +4,7 @@ import { DefaultRelays } from 'Const';
import { HexKey, TaggedRawEvent } from 'Nostr'; import { HexKey, TaggedRawEvent } from 'Nostr';
import { RelaySettings } from 'Nostr/Connection'; import { RelaySettings } from 'Nostr/Connection';
import type { AppDispatch, RootState } from "State/Store"; import type { AppDispatch, RootState } from "State/Store";
import { ImgProxySettings } from 'Feed/ImgProxy';
const PrivateKeyItem = "secret"; const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey"; const PublicKeyItem = "pubkey";
@ -56,9 +57,9 @@ export interface UserPreferences {
fileUploader: "void.cat" | "nostr.build", fileUploader: "void.cat" | "nostr.build",
/** /**
* Use image proxy service to compress avatars * Use imgproxy to optimize images
*/ */
useImageProxy: boolean, imgProxyConfig: ImgProxySettings | null
} }
export interface LoginStore { export interface LoginStore {
@ -138,7 +139,7 @@ export interface LoginStore {
preferences: UserPreferences preferences: UserPreferences
}; };
const InitState = { export const InitState = {
loggedOut: undefined, loggedOut: undefined,
publicKey: undefined, publicKey: undefined,
privateKey: undefined, privateKey: undefined,
@ -161,7 +162,11 @@ const InitState = {
showDebugMenus: false, showDebugMenus: false,
autoShowLatest: false, autoShowLatest: false,
fileUploader: "void.cat", fileUploader: "void.cat",
useImageProxy: true imgProxyConfig: {
url: "https://imgproxy.snort.social",
key: "a82fcf26aa0ccb55dfc6b4bd6a1c90744d3be0f38429f21a8828b43449ce7cebe6bdc2b09a827311bef37b18ce35cb1e6b1c60387a254541afa9e5b4264ae942",
salt: "a897770d9abf163de055e9617891214e75a9016d748f8ef865e6ffbcb9ed932295659549773a22a019a5f06d0b440c320be411e3fddfe784e199e4f03d74bd9b"
}
} }
} as LoginStore; } as LoginStore;

View File

@ -327,18 +327,24 @@ a.ext {
white-space: initial; white-space: initial;
} }
div.form {
display: grid;
grid-auto-flow: row;
}
div.form-group { div.form-group {
display: flex; display: grid;
align-items: center; align-items: center;
grid-template-columns: 1fr 2fr;
} }
div.form-group>div { div.form-group>div {
padding: 3px 5px; padding: 3px 5px;
word-break: break-word;
} }
div.form-group>div:nth-child(1) { div.form-group>div:nth-child(1) {
min-width: 100px; display: flex;
align-self: center;
} }
div.form-group>div:nth-child(2) { div.form-group>div:nth-child(2) {
@ -351,6 +357,11 @@ div.form-group>div:nth-child(2) input {
flex-grow: 1; flex-grow: 1;
} }
div.form-col {
grid-auto-flow: row;
grid-template-columns: auto;
}
.modal { .modal {
position: absolute; position: absolute;
width: 100vw; width: 100vw;
@ -377,7 +388,7 @@ body.scroll-lock {
height: 100vh; height: 100vh;
} }
.pointer { .pointer {
cursor: pointer; cursor: pointer;
} }
@ -485,6 +496,10 @@ body.scroll-lock {
} }
@media(max-width: 720px) { @media(max-width: 720px) {
div.form {
grid-auto-flow: dense;
}
div.form-group { div.form-group {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@ -507,4 +522,4 @@ body.scroll-lock {
.bold { .bold {
font-weight: 700; font-weight: 700;
} }