diff --git a/README.md b/README.md index 4c6aa8f0..950dafd2 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,9 @@ yarn yarn build yarn preview ``` + 4. open one of the URLs listed in your browser + ### Social - We have an [e6 forum thread](https://e621.net/forum_topics/23796). We'd love to hear your feedback. diff --git a/src/e621/E621Credentials.tsx b/src/e621/E621Credentials.tsx index 106b8675..d7ce609a 100644 --- a/src/e621/E621Credentials.tsx +++ b/src/e621/E621Credentials.tsx @@ -2,6 +2,7 @@ import styled from 'styled-components'; import { E621Service } from './E621Service'; import { useCallback, useMemo, useState } from 'react'; import { + Button, SettingsInfo, SettingsLabel, Space, @@ -30,21 +31,6 @@ const StyledE621SaveCredentials = styled.div` align-items: center; `; -const StyledE621SaveCredentialsButton = styled.button` - background: var(--button-background); - color: var(--button-color); - border-radius: var(--border-radius); - padding: 4px 8px; - transition: - background 0.2s, - opacity 0.2s; - cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')}; - &:hover { - background: var(--primary); - } - opacity: ${props => (props.disabled ? 0.5 : 1)}; -`; - export const E621CredentialsInput = ({ service, initialValue, @@ -85,6 +71,7 @@ export const E621CredentialsInput = ({ Username @@ -100,6 +87,7 @@ export const E621CredentialsInput = ({ API Key @@ -117,12 +105,12 @@ export const E621CredentialsInput = ({ {loading && } - Save - + ); diff --git a/src/e621/E621Search.tsx b/src/e621/E621Search.tsx index 0a2096d3..fba2092a 100644 --- a/src/e621/E621Search.tsx +++ b/src/e621/E621Search.tsx @@ -94,7 +94,7 @@ export const E621Search = () => { return ( - Add images from e621 + Add images from e621's search Tags - - - - - + + + + + + + diff --git a/src/local/LocalImport.tsx b/src/local/LocalImport.tsx index 2d8a1b4e..d7290a5e 100644 --- a/src/local/LocalImport.tsx +++ b/src/local/LocalImport.tsx @@ -86,7 +86,9 @@ export const LocalImport = () => { return ( - Add images from your device + + Add images from your device storage + = { e621: , @@ -29,7 +30,14 @@ export const ServiceSettings = () => { }> + Walltaker + + ), + }, { id: 'local', content: 'Device' }, ]} current={activeTab} diff --git a/src/settings/components/WalltakerSettings.tsx b/src/settings/components/WalltakerSettings.tsx deleted file mode 100644 index 4dc674d5..00000000 --- a/src/settings/components/WalltakerSettings.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { SettingsTile } from '../../common'; -import { faPersonDigging } from '@fortawesome/free-solid-svg-icons'; - -export const WalltakerSettings = () => { - return ( - - - - - - There is nothing here yet :3 - - - ); -}; diff --git a/src/settings/components/index.ts b/src/settings/components/index.ts index bf45d051..c20b5673 100644 --- a/src/settings/components/index.ts +++ b/src/settings/components/index.ts @@ -9,4 +9,3 @@ export * from './PlayerSettings'; export * from './ServiceSettings'; export * from './TradeSettings'; export * from './VibratorSettings'; -export * from './WalltakerSettings'; diff --git a/src/types/index.ts b/src/types/index.ts index 007195a8..f0534e77 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,3 +3,4 @@ export * from './event'; export * from './gender'; export * from './hypno'; export * from './image'; +export * from './walltaker'; diff --git a/src/types/walltaker.ts b/src/types/walltaker.ts new file mode 100644 index 00000000..9e8a14cb --- /dev/null +++ b/src/types/walltaker.ts @@ -0,0 +1,4 @@ +export interface WalltakerConfig { + enabled: boolean; + id: number | null; +} diff --git a/src/utils/state.tsx b/src/utils/state.tsx index 6b643a02..d5a85270 100644 --- a/src/utils/state.tsx +++ b/src/utils/state.tsx @@ -14,6 +14,8 @@ interface StateContextType { setData: React.Dispatch>; } +export type StateAndSetter = [T, React.Dispatch>]; + export function createStateProvider({ defaultData: globalDefaultData, }: StateProviderOptions) { @@ -48,7 +50,7 @@ export function createStateProvider({ ); }; - const useProvider = (): [T, React.Dispatch>] => { + const useProvider = (): StateAndSetter => { const context = useContext(StateContext); if (!context) { throw new Error('useProvider must be used within its Provider'); diff --git a/src/walltaker/WalltakerCredentials.tsx b/src/walltaker/WalltakerCredentials.tsx new file mode 100644 index 00000000..5d4f119b --- /dev/null +++ b/src/walltaker/WalltakerCredentials.tsx @@ -0,0 +1,104 @@ +import styled from 'styled-components'; +import { useCallback, useMemo, useState } from 'react'; +import { Button, SettingsLabel, Space, Spinner, TextInput } from '../common'; +import { WalltakerService } from './WalltakerService'; +import { WalltakerCredentials } from './WalltakerProvider'; + +export interface WalltakerCredentialsInputProps { + service: WalltakerService; + initialValue?: Partial; + onSaved?: (credentials: WalltakerCredentials) => void; + disabled?: boolean; +} + +const StyledE621CredentialsInput = styled.div` + grid-column: 1 / -1; + display: grid; + grid-template-columns: auto 1fr auto; +`; + +const StyledE621SaveCredentials = styled.div` + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + align-items: center; +`; + +export const WalltakerCredentialsInput = ({ + service, + initialValue, + onSaved, + disabled, +}: WalltakerCredentialsInputProps) => { + const [input, setInput] = useState>({ + ...initialValue, + username: '', + apiKey: '', + }); + const [loading, setLoading] = useState(false); + + const onSave = useCallback(async () => { + const credentials = input as WalltakerCredentials; + setLoading(true); + const valid = await service.testCredentials(credentials); + if (valid) { + onSaved?.(credentials); + } + }, [input, onSaved, service]); + + const hasData = useMemo( + () => input?.username && input?.apiKey, + [input?.username, input?.apiKey] + ); + + const locked = useMemo(() => loading || disabled, [loading, disabled]); + + return ( + + + Username + + setInput({ + ...input, + username, + }) + } + placeholder='Username' + autoComplete='username' + disabled={locked} + /> + + API Key + + setInput({ + ...input, + apiKey, + }) + } + placeholder='API Key' + type='password' + autoComplete='current-password' + disabled={locked} + /> + + + {loading && } + + + Save + + + + ); +}; diff --git a/src/walltaker/WalltakerOnline.tsx b/src/walltaker/WalltakerOnline.tsx new file mode 100644 index 00000000..512cca44 --- /dev/null +++ b/src/walltaker/WalltakerOnline.tsx @@ -0,0 +1,54 @@ +import styled, { keyframes } from 'styled-components'; +import { useWalltaker } from './WalltakerProvider'; + +const StyledOnlineDotContainer = styled.div` + display: flex; + height: 1rem; + position: relative; +`; + +const StyledOnlineDot = styled.div<{ + $online?: boolean; +}>` + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background-color: ${({ $online }) => ($online ? '#30AA65' : 'transparent')}; + border: ${({ $online }) => ($online ? 'transparent' : '2px solid grey')}; +`; + +const pulse = keyframes` + 0% { + transform: scale(1); + opacity: 0.4; + } + 100% { + transform: scale(2.5); + opacity: 0; + } +`; + +const PulseAnimation = styled.div` + position: absolute; + top: 0%; + left: 0%; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background-color: #30aa65; + transform: translate(-50%, -50%); + animation: ${pulse} 2s infinite ease-out; +`; + +export const WalltakerOnline = () => { + const { + data: { connected }, + } = useWalltaker(); + + return ( + + + {connected && } + + ); +}; diff --git a/src/walltaker/WalltakerProvider.tsx b/src/walltaker/WalltakerProvider.tsx new file mode 100644 index 00000000..32c6eab3 --- /dev/null +++ b/src/walltaker/WalltakerProvider.tsx @@ -0,0 +1,142 @@ +/* eslint-disable react-refresh/only-export-components */ +import { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { createLocalStorageProvider, StateAndSetter } from '../utils'; +import { WalltakerLink, WalltakerService } from './WalltakerService'; +import { uniqBy } from 'lodash'; +import { useImages } from '../settings'; +import { ImageServiceType, ImageType } from '../types'; + +export interface WalltakerCredentials { + username: string; + apiKey?: string; +} + +export interface WalltakerSettings { + enabled?: boolean; + credentials?: WalltakerCredentials; + ids?: number[]; +} + +const walltakerStorageKey = 'walltaker'; + +const { + Provider: WalltakerSettingsProvider, + useProvider: useWalltakerSettings, +} = createLocalStorageProvider({ + key: walltakerStorageKey, + defaultData: {}, +}); + +const WalltakerServiceProvider: React.FC = ({ + children, +}) => { + const settingsRef = useWalltakerSettings(); + const service = useMemo(() => new WalltakerService(), []); + const [, setImages] = useImages(); + + const [settings] = settingsRef; + + const [data, setData] = useState({}); + + const onLink = useCallback( + (link: WalltakerLink) => { + setData(prev => ({ + ...prev, + links: uniqBy([link, ...(prev.links ?? [])], 'id'), + })); + setImages(prev => + uniqBy( + [ + { + thumbnail: link.post_thumbnail_url, + preview: link.post_thumbnail_url, + full: link.post_url, + type: ImageType.image, + source: link.post_url, + service: ImageServiceType.e621, + id: link.post_url, + }, + ...prev, + ], + 'id' + ) + ); + }, + [setImages] + ); + + useEffect(() => { + if (settings.enabled) { + service + .connect() + .then(() => { + setData(prev => ({ ...prev, connected: true })); + service.addLinkListener(onLink); + }) + .catch(() => setData({ connected: false })); + } + + return () => { + service.disconnect().then(() => { + setData(prev => ({ ...prev, connected: false })); + service.removeLinkListener(onLink); + }); + }; + }, [onLink, service, settings.enabled]); + + useEffect(() => { + if (data.connected) { + for (const id of settings.ids ?? []) { + service.listenTo(id); + } + // TODO: add past history? + } + }, [data.connected, service, settings.enabled, settings.ids]); + + return ( + + {children} + + ); +}; + +export interface WalltakerData { + connected?: boolean; + links?: WalltakerLink[]; +} + +export interface WalltakerContext { + settings: StateAndSetter; + service: WalltakerService; + data: WalltakerData; +} + +const WalltakerContext = createContext(null); + +export const WalltakerProvider: React.FC = ({ + children, +}) => { + return ( + + {children} + + ); +}; + +export const useWalltaker = (): WalltakerContext => { + const context = useContext(WalltakerContext); + + if (!context) { + throw new Error('useWalltaker must be used within a WalltakerProvider'); + } + + return context; +}; diff --git a/src/walltaker/WalltakerSearch.tsx b/src/walltaker/WalltakerSearch.tsx index 50d63e09..3e8d14b4 100644 --- a/src/walltaker/WalltakerSearch.tsx +++ b/src/walltaker/WalltakerSearch.tsx @@ -1,22 +1,204 @@ +import { + faCheckSquare, + faMinusSquare, + faRightFromBracket, + faRss, + faSpinner, + faUser, +} from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faPersonDigging } from '@fortawesome/free-solid-svg-icons'; +import { + ToggleTile, + ToggleTileType, + SettingsDescription, + Surrounded, + IconButton, +} from '../common'; +import styled from 'styled-components'; +import { useWalltaker } from './WalltakerProvider'; +import { AnimatePresence, motion } from 'framer-motion'; +import { defaultTransition } from '../utils'; +import { useEffect, useState } from 'react'; +import { WalltakerLink } from './WalltakerService'; +import { WalltakerCredentialsInput } from './WalltakerCredentials'; +import { faSquare } from '@fortawesome/free-regular-svg-icons'; + +const StyledWalltakerSearch = styled.div` + display: grid; + grid-template-columns: auto 1fr auto; +`; export const WalltakerSearch = () => { + const { + service, + settings: [settings, setSettings], + data: { connected }, + } = useWalltaker(); + + const [allLinks, setAllLinks] = useState([]); + const [loading] = useState(false); + + useEffect(() => { + if (settings.credentials) { + service.getUserLinks(settings.credentials).then(links => { + setAllLinks(links); + }); + } + }, [service, settings]); + return ( - - - - - There is nothing here yet :3 - + + + Let other users add images to your session live! + + + {settings.credentials ? ( + <> + } + onClick={() => { + setSettings({ ...settings, credentials: undefined }); + }} + style={{ + padding: '10px', + }} + /> + } + > + + + } + > + {settings.credentials.username} + + You are connected + + + { + setSettings({ ...settings, enabled: !settings.enabled }); + }} + type={ToggleTileType.radio} + trailing={ + settings.enabled && !connected ? ( + + ) : ( + + ) + } + > + Enable + Receive images from other users for this session + + + {settings.enabled && ( + + + 0 + ? 'Deselect all links' + : 'Select all links' + } + icon={ + { + if ( + settings.ids && + allLinks.length > 0 && + settings.ids.length === allLinks.length + ) { + return faCheckSquare; + } else if ( + settings.ids && + settings.ids.length > 0 + ) { + return faMinusSquare; + } else { + return faSquare; + } + })()} + /> + } + onClick={() => { + if ( + settings.ids && + settings.ids.length === allLinks.length + ) { + setSettings({ ...settings, ids: [] }); + } else { + setSettings({ + ...settings, + ids: allLinks.map(link => link.id), + }); + } + }} + /> + } + > + Links + + + {allLinks.map(link => { + return ( + { + if (settings.ids?.includes(link.id)) { + setSettings({ + ...settings, + ids: settings.ids?.filter(id => id !== link.id), + }); + } else { + setSettings({ + ...settings, + ids: [...(settings.ids ?? []), link.id], + }); + } + }} + type={ToggleTileType.check} + > + #{link.id} + + + ); + })} + + )} + > + ) : ( + { + setSettings({ ...settings, credentials }); + }} + disabled={loading} + /> + )} + + ); }; diff --git a/src/walltaker/WalltakerService.ts b/src/walltaker/WalltakerService.ts new file mode 100644 index 00000000..936b146a --- /dev/null +++ b/src/walltaker/WalltakerService.ts @@ -0,0 +1,224 @@ +import axios, { AxiosInstance } from 'axios'; +import { isEqual } from 'lodash'; +import { WalltakerCredentials } from './WalltakerProvider'; + +export interface WalltakerLink { + id: number; + expires: string; + terms: string; + blacklist: string; + post_url: string; + post_thumbnail_url: string; + post_description: string; + created_at: string; + updated_at: string; + response_type?: string; + response_text?: string; + username: string; + online: boolean; +} + +export interface WalltakerUser { + username: string; + id: number; + set_count: number; + online: boolean; + authenticated: boolean; + links: WalltakerLink[]; +} + +export class WalltakerService { + constructor() { + this.axiosInstance = axios.create({ + baseURL: 'https://walltaker.joi.how/api', + }); + } + + private axiosInstance: AxiosInstance; + private socket: WebSocket | null = null; + + private subscriptions: number[] = []; + + get subcriptions() { + return this.subscriptions.slice(); + } + + connect(): Promise { + return new Promise((res, rej) => { + if (this.socket) { + switch (this.socket.readyState) { + case this.socket.OPEN: + res(); + return; + case this.socket.CONNECTING: + this.socket.addEventListener('open', () => res()); + this.socket.addEventListener('error', () => rej()); + return; + case this.socket.CLOSING: + case this.socket.CLOSED: + this.socket = null; + return this.connect(); + } + } + + const socket = new WebSocket('wss://walltaker.joi.how/cable'); + this.socket = socket; + + this.socket.addEventListener('message', this.onAnyMessage.bind(this)); + this.socket.addEventListener('message', this.pingEcho); + + this.socket.addEventListener('open', () => res()); + this.socket.addEventListener('error', () => rej()); + }); + } + + private pingEcho = (message: MessageEvent) => { + if (!this.socket) return; + const content = JSON.parse(message.data); + if (content.type === 'ping') { + this.socket.send(JSON.stringify({ type: 'pong' })); + } + }; + + private consumeUntil = async ( + predicate: (message: MessageEvent) => boolean, + timeout: number = 5000 + ) => { + return new Promise>((res, rej) => { + if (!this.socket) { + rej('No socket connected.'); + return; + } + + const timer = setTimeout(() => { + this.socket?.removeEventListener('message', handler); + rej('Timeout reached.'); + }, timeout); + + const handler = (message: MessageEvent) => { + if (predicate(message)) { + this.socket?.removeEventListener('message', handler); + clearTimeout(timer); + res(message); + } + }; + + this.socket.addEventListener('message', handler); + this.socket.addEventListener('close', () => rej('Socket closed.')); + }); + }; + + disconnect(): Promise { + return new Promise(res => { + if (this.socket) { + this.socket.addEventListener('close', () => res()); + this.socket.close(); + } + this.socket = null; + this.subscriptions = []; + }); + } + + listenTo(id: number): Promise { + return new Promise((resolve, reject) => { + const eventName = 'confirm_subscription'; + + if (!this.socket) { + reject('No socket connected.'); + return; + } + + const channelId = this.channelIdentifierFor(id); + const message = { + command: 'subscribe', + identifier: JSON.stringify(channelId), + }; + + this.socket.send(JSON.stringify(message)); + + resolve( + this.consumeUntil((message: MessageEvent) => { + const content = JSON.parse(message.data); + if (content.identifier) { + content.identifier = JSON.parse(content.identifier); + } + + return ( + content.type === eventName && + isEqual(content.identifier, this.channelIdentifierFor(id)) + ); + }) + .then(() => { + console.log('Handshake confirmed for Link#' + id); + this.subscriptions.push(id); + }) + .catch(error => { + reject(`Failed to confirm handshake for Link#${id}: ${error}`); + }) + ); + }); + } + + muteFrom(id: number): Promise { + return new Promise((res, rej) => { + if (!this.socket) { + rej('No socket connected.'); + return; + } + + const channelId = this.channelIdentifierFor(id); + const message = { + command: 'unsubscribe', + identifier: JSON.stringify(channelId), + }; + + this.socket.send(JSON.stringify(message)); + this.subscriptions = this.subscriptions.filter(sub => sub !== id); + res(); + }); + } + + private channelIdentifierFor(id: number) { + return { channel: 'LinkChannel', id }; + } + + async getLink(id: number) { + return this.axiosInstance + .get(`/links/${id}.json`) + .then(res => res.data); + } + + async getUserLinks(credentials: WalltakerCredentials) { + return this.axiosInstance + .get(`/users/${credentials.username}.json`, { + params: { + api_key: credentials.apiKey, + }, + headers: { + // Something like this; double check with Gray + // Authorization: `Bearer ${credentials.apiKey}`, + }, + }) + .then(res => res.data.links); + } + + private onAnyMessage(message: MessageEvent) { + const content = JSON.parse(message.data); + if (content.message?.success === true && content.message?.post_url) { + this.listeners.forEach(listener => + listener(content.message as WalltakerLink) + ); + } + console.log(content); + } + + private listeners: ((link: WalltakerLink) => void)[] = []; + + addLinkListener(callback: (link: WalltakerLink) => void) { + this.listeners.push(callback); + } + + removeLinkListener(callback: (link: WalltakerLink) => void) { + this.listeners = this.listeners.filter(listener => listener !== callback); + } +} diff --git a/src/walltaker/index.ts b/src/walltaker/index.ts index e27d8970..5dbaf555 100644 --- a/src/walltaker/index.ts +++ b/src/walltaker/index.ts @@ -1 +1,4 @@ +export * from './WalltakerOnline'; +export * from './WalltakerProvider'; +export * from './WalltakerService'; export * from './WalltakerSearch';
There is nothing here yet :3
You are connected
Receive images from other users for this session