mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-18 19:23:40 +00:00
Add image uploads to profile edit page
This commit is contained in:
parent
91df2e2247
commit
490f38fb5e
22
ROADMAP.md
22
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
|
||||
|
@ -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'
|
||||
|
86
src/partials/ImageInput.svelte
Normal file
86
src/partials/ImageInput.svelte
Normal 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}
|
@ -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")
|
||||
|
||||
|
@ -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.
|
||||
@ -278,3 +285,45 @@ export const quantile = (a, q) => {
|
||||
? 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})
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
})
|
||||
|
||||
|
@ -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)),
|
||||
})
|
||||
|
||||
|
@ -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 () => {
|
||||
|
Loading…
Reference in New Issue
Block a user