Add image uploads to profile edit page

This commit is contained in:
Jonathan Staab 2023-03-01 14:16:30 -06:00
parent 91df2e2247
commit 490f38fb5e
9 changed files with 201 additions and 61 deletions

View File

@ -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

View File

@ -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'

View File

@ -0,0 +1,86 @@
<script lang="ts">
import {filter, identity} from 'ramda'
import Input from "src/partials/Input.svelte"
import Modal from "src/partials/Modal.svelte"
import Content from "src/partials/Content.svelte"
import Anchor from "src/partials/Anchor.svelte"
import {listenForFile, stripExifData, blobToFile} from "src/util/html"
import {uploadFile, postJson} from "src/util/misc"
import user from "src/agent/user"
export let value
export let icon
export let maxWidth = null
export let maxHeight = null
let input, file, listener, quote
let loading = false
let isOpen = false
$: {
if (input) {
listener = listenForFile(input, async inputFile => {
const opts = filter(identity, {maxWidth, maxHeight})
file = blobToFile(await stripExifData(inputFile, opts))
quote = await postJson(user.dufflepud('/upload/quote'), {
uploads: [{size: file.size}],
})
})
}
}
const accept = async () => {
loading = true
try {
const {id} = quote.uploads[0]
const {url} = await uploadFile(user.dufflepud(`/upload/${id}`), file)
value = url
} finally {
loading = false
}
file = null
quote = null
isOpen = false
}
const decline = () => {
file = null
quote = null
isOpen = false
}
</script>
<div class="flex gap-2">
<Input type="text" wrapperClass="flex-grow" bind:value={value} placeholder="https://">
<i slot="before" class={`fa fa-${icon}`} />
</Input>
<Anchor type="button" on:click={() => { isOpen = true }}>
<i class="fa fa-upload" />
</Anchor>
</div>
{#if quote}
<Modal onEscape={decline}>
<Content>
<h1 class="staatliches text-2xl">Confirm File Upload</h1>
<p>Please accept the following terms:</p>
<p>{quote.terms}</p>
<div class="flex gap-2">
<Anchor type="button" on:click={decline} {loading}>Decline</Anchor>
<Anchor type="button-accent" on:click={accept} {loading}>Accept</Anchor>
</div>
</Content>
</Modal>
{:else if isOpen}
<Modal onEscape={decline}>
<Content>
<h1 class="staatliches text-2xl">Upload a File</h1>
<p>Click below to select a file to upload.</p>
<input type="file" bind:this={input} />
</Content>
</Modal>
{/if}

View File

@ -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")

View File

@ -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<string, string | boolean>
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})
}

View File

@ -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'))
}
</script>
@ -91,28 +73,19 @@
</div>
<div class="flex flex-col gap-1">
<strong>Profile Picture</strong>
<!-- <input type="file" name="picture" />
<ImageInput bind:value={values.picture} icon="image-portrait" maxWidth={480} maxHeight={480} />
<p class="text-sm text-light">
Your profile image will have all metadata removed before being published.
</p> -->
<Input type="text" name="picture" wrapperClass="flex-grow" bind:value={values.picture}>
<i slot="before" class="fa fa-image-portrait" />
</Input>
<p class="text-sm text-light">
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.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Profile Banner</strong>
<Input type="text" name="banner" wrapperClass="flex-grow" bind:value={values.banner}>
<i slot="before" class="fa fa-panorama" />
</Input>
<ImageInput bind:value={values.banner} icon="panorama" />
<p class="text-sm text-light">
Enter a url to your profile's banner image.
In most clients, this image will be shown on your profile page.
</p>
</div>
<Button type="submit" class="text-center">Done</Button>
<Button type="submit" class="text-center">Save</Button>
</div>
</Content>
</form>

View File

@ -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,
})

View File

@ -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)),
})

View File

@ -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 () => {