From 490f38fb5e94f94a127c515906ba4919af646d98 Mon Sep 17 00:00:00 2001 From: Jonathan Staab Date: Wed, 1 Mar 2023 14:16:30 -0600 Subject: [PATCH] Add image uploads to profile edit page --- ROADMAP.md | 22 ++++-- src/agent/network.ts | 2 +- src/partials/ImageInput.svelte | 86 ++++++++++++++++++++++++ src/util/html.ts | 36 ++++++++-- src/util/misc.ts | 63 +++++++++++++++-- src/views/Profile.svelte | 41 ++--------- src/views/chat/ChatDetail.svelte | 4 +- src/views/messages/MessagesDetail.svelte | 4 +- src/views/notes/Feed.svelte | 4 +- 9 files changed, 201 insertions(+), 61 deletions(-) create mode 100644 src/partials/ImageInput.svelte diff --git a/ROADMAP.md b/ROADMAP.md index 74bdd290..13a4daeb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,18 @@ # Current +- [ ] Strip zero width spaces from compose - [ ] Fix iOS - [ ] Make the note relays button modal make sense, one relay with no explanation is not good +# Image uploads + +- Default will charge via lightning and have a tos, others can self-host and skip that. +- https://github.com/ElementsProject/lightning-charge +- https://github.com/nostr-protocol/nips/pull/250 +- https://github.com/brandonsavage/Upload +- https://github.com/seaweedfs/seaweedfs +- https://github.com/cubefs/cubefs + # Lightning - [ ] Linkify invoices @@ -18,6 +28,11 @@ # More +- [ ] Add webtorrent support + - https://coracle.social/nevent1qqsxgxcsq5vevy4wdty5z5v88nhwp2fc5qgl0ws5rmamn6z72hwv3qcpyfmhxue69uhkummnw3ez6an9wf5kv6t9vsh8wetvd3hhyer9wghxuet5qk6c9q +- [ ] Add coracle relay + - Authenticated write, public read + - Only accepts events from people with a @coracle.social nip05 - [ ] Apply person popover to mentions in notes as well - [ ] Invite link, nprofile + path that prompts someone to sign in or create an account and auto-follow the inviter - [ ] Cache follower numbers to avoid re-fetching so much @@ -63,13 +78,6 @@ - All replies from author's + user's read relays, including spam - [ ] Topics/hashtag views - [ ] Re-license using https://polyformproject.org/ -- [ ] Image uploads - - Default will charge via lightning and have a tos, others can self-host and skip that. - - Add banner field to profile - - Linode/Digital Ocean - - https://github.com/brandonsavage/Upload - - https://github.com/seaweedfs/seaweedfs - - https://github.com/cubefs/cubefs - [ ] Separate settings for read, write, and broadcast relays based on NIP 65 - [ ] Release to android - https://svelte-native.technology/docs diff --git a/src/agent/network.ts b/src/agent/network.ts index 5ea7794b..ae79e34d 100644 --- a/src/agent/network.ts +++ b/src/agent/network.ts @@ -1,5 +1,5 @@ import type {MyEvent} from 'src/util/types' -import {assoc, partition, uniq, uniqBy, prop, propEq, reject, groupBy, pluck} from 'ramda' +import {partition, assoc, uniq, uniqBy, prop, propEq, reject, groupBy, pluck} from 'ramda' import {personKinds, findReplyId} from 'src/util/nostr' import {log} from 'src/util/logger' import {chunk} from 'hurdak/lib/hurdak' diff --git a/src/partials/ImageInput.svelte b/src/partials/ImageInput.svelte new file mode 100644 index 00000000..0785ab2a --- /dev/null +++ b/src/partials/ImageInput.svelte @@ -0,0 +1,86 @@ + + +
+ + + + { isOpen = true }}> + + +
+ +{#if quote} + + +

Confirm File Upload

+

Please accept the following terms:

+

{quote.terms}

+
+ Decline + Accept +
+
+
+{:else if isOpen} + + +

Upload a File

+

Click below to select a file to upload.

+ +
+
+{/if} diff --git a/src/util/html.ts b/src/util/html.ts index 8f97e46b..c1878337 100644 --- a/src/util/html.ts +++ b/src/util/html.ts @@ -1,4 +1,4 @@ -import {ellipsize} from 'hurdak/lib/hurdak' +import {ellipsize, bytes} from 'hurdak/lib/hurdak' export const copyToClipboard = text => { const {activeElement} = document @@ -16,7 +16,7 @@ export const copyToClipboard = text => { return result } -export const stripExifData = async file => { +export const stripExifData = async (file, opts = {}) => { if (window.DataTransferItem && file instanceof DataTransferItem) { file = file.getAsFile() } @@ -31,9 +31,10 @@ export const stripExifData = async file => { return new Promise((resolve, _reject) => { new Compressor(file, { - maxWidth: 50, - maxHeight: 50, - convertSize: 1024, + maxWidth: 1024, + maxHeight: 1024, + convertSize: bytes(1, 'mb'), + ...opts, success: resolve, error: e => { // Non-images break compressor @@ -47,6 +48,31 @@ export const stripExifData = async file => { }) } +export const listenForFile = (input, onChange) => { + input.addEventListener('change', async e => { + const target = e.target as HTMLInputElement + const [file] = target.files + + if (file) { + onChange(file) + } else { + onChange(null) + } + }) +} + +export const blobToString = async blob => + new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onerror = reject + reader.onload = () => resolve(reader.result) + reader.readAsDataURL(blob) + }) + +export const blobToFile = blob => + new File([blob], blob.name, {type: blob.type}) + export const escapeHtml = html => { const div = document.createElement("div") diff --git a/src/util/misc.ts b/src/util/misc.ts index 47fe342b..ceabc65f 100644 --- a/src/util/misc.ts +++ b/src/util/misc.ts @@ -152,6 +152,13 @@ export class Cursor { this.until = now() this.limit = limit } + getFilter() { + return { + until: this.until, + since: this.until - timedelta(8, 'hours'), + limit: this.limit, + } + } update(events) { // update takes all events in a feed and figures out the best place to set `until` // in order to find older events without re-fetching events that we've already seen. @@ -269,12 +276,54 @@ export const difference = (a, b) => new Set(Array.from(a).filter(x => !b.has(x))) export const quantile = (a, q) => { - const sorted = sortBy(identity, a) - const pos = (sorted.length - 1) * q - const base = Math.floor(pos) - const rest = pos - base + const sorted = sortBy(identity, a) + const pos = (sorted.length - 1) * q + const base = Math.floor(pos) + const rest = pos - base - return isNil(sorted[base + 1]) - ? sorted[base] - : sorted[base] + rest * (sorted[base + 1] - sorted[base]) + return isNil(sorted[base + 1]) + ? sorted[base] + : sorted[base] + rest * (sorted[base + 1] - sorted[base]) +} + +type FetchOpts = { + method?: string + headers?: Record + body?: string | FormData +} + +export const fetchJson = async (url, opts: FetchOpts = {}) => { + if (!opts.headers) { + opts.headers = {} + } + + opts.headers['Accept'] = 'application/json' + + const res = await fetch(url, opts as RequestInit) + const json = await res.json() + + return json +} + +export const postJson = async (url, data, opts: FetchOpts = {}) => { + if (!opts.method) { + opts.method = 'POST' + } + + if (!opts.headers) { + opts.headers = {} + } + + opts.headers['Content-Type'] = 'application/json' + opts.body = JSON.stringify(data) + + return fetchJson(url, opts) +} + +export const uploadFile = (url, fileObj) => { + const body = new FormData() + + body.append("file", fileObj) + + return fetchJson(url, {method: 'POST', body}) } diff --git a/src/views/Profile.svelte b/src/views/Profile.svelte index 3514c2b3..b8def701 100644 --- a/src/views/Profile.svelte +++ b/src/views/Profile.svelte @@ -2,8 +2,8 @@ import {onMount} from "svelte" import {fly} from 'svelte/transition' import {navigate} from "svelte-routing" - // import {stripExifData} from "src/util/html" import Input from "src/partials/Input.svelte" + import ImageInput from "src/partials/ImageInput.svelte" import Textarea from "src/partials/Textarea.svelte" import Anchor from "src/partials/Anchor.svelte" import Button from "src/partials/Button.svelte" @@ -24,29 +24,11 @@ if (!user.getProfile()) { return navigate("/login") } - - /* - document.querySelector('[name=picture]').addEventListener('change', async e => { - const target = e.target as HTMLInputElement - const [file] = target.files - - if (file) { - const reader = new FileReader() - reader.onerror = error - reader.onload = () => values.picture = reader.result - reader.readAsDataURL(await stripExifData(file)) - } else { - values.picture = null - } - }) - */ }) const submit = async event => { - event.preventDefault() - + event?.preventDefault() publishWithToast(getUserWriteRelays(), cmd.updateUser(values)) - navigate(routes.person(user.getPubkey(), 'profile')) } @@ -91,28 +73,19 @@
Profile Picture - - - - -

- Enter a url to your profile image - please be mindful of others and - only use small images. + Please be mindful of others and only use small images.

Profile Banner - - - +

- Enter a url to your profile's banner image. + In most clients, this image will be shown on your profile page.

- + diff --git a/src/views/chat/ChatDetail.svelte b/src/views/chat/ChatDetail.svelte index 2146e062..7ce5918a 100644 --- a/src/views/chat/ChatDetail.svelte +++ b/src/views/chat/ChatDetail.svelte @@ -28,10 +28,10 @@ onChunk, }) - const loadMessages = ({until, limit}, onChunk) => + const loadMessages = (cursor, onChunk) => network.load({ relays: getRelays(), - filter: {kinds: [42], '#e': [id], until, limit}, + filter: {kinds: [42], '#e': [id], ...cursor.getFilter()}, onChunk, }) diff --git a/src/views/messages/MessagesDetail.svelte b/src/views/messages/MessagesDetail.svelte index 275f51d7..dd63829d 100644 --- a/src/views/messages/MessagesDetail.svelte +++ b/src/views/messages/MessagesDetail.svelte @@ -54,10 +54,10 @@ onChunk: async events => onChunk(await decryptMessages(events)), }) - const loadMessages = ({until, limit}, onChunk) => + const loadMessages = (cursor, onChunk) => network.load({ relays: getRelays(), - filter: getFilters({until, limit}), + filter: getFilters(cursor.getFilter()), onChunk: async events => onChunk(await decryptMessages(events)), }) diff --git a/src/views/notes/Feed.svelte b/src/views/notes/Feed.svelte index 96881d91..27c91717 100644 --- a/src/views/notes/Feed.svelte +++ b/src/views/notes/Feed.svelte @@ -85,9 +85,7 @@ return } - const {limit, until} = cursor - - return network.load({relays, filter: {...filter, until, limit}, onChunk}) + return network.load({relays, filter: {...filter, ...cursor.getFilter()}, onChunk}) }) return () => {