Add analytics

This commit is contained in:
Jonathan Staab 2023-01-21 11:15:10 -08:00
parent 1a9a31b45b
commit ef3b7f2d3d
20 changed files with 194 additions and 97 deletions

View File

@ -42,6 +42,11 @@ If you like Coracle and want to support its development, you can donate sats via
- [ ] Add Labs tab with cards for non-standard features
- Time travel - see events as of a date/time
# Bugs
- [ ] Sync mentions box and in-reply mentions
# Changelog
## Current
@ -53,7 +58,14 @@ If you like Coracle and want to support its development, you can donate sats via
- [ ] Add petnames for channels
- [ ] Add back button
- [ ] Create Room -> open modal, choose dm or public room
- [ ] Add DM button to profile pages
- [x] Add DM button to profile pages
- [ ] Linkify bech32 entities
- [ ] linkify dm page header
- [ ] Add lock/unlock icon to channel header
- [ ] Add notification for dms
- [ ] Default to network/following
- [ ] Add analytics
- [ ] Allow disabling error reporting/analytics
## 0.2.7

View File

@ -2,7 +2,7 @@
import "@fortawesome/fontawesome-free/css/fontawesome.css"
import "@fortawesome/fontawesome-free/css/solid.css"
import {find, pluck} from 'ramda'
import {find, nthArg, pluck} from 'ramda'
import {onMount} from "svelte"
import {writable, get} from "svelte/store"
import {fly, fade} from "svelte/transition"
@ -12,7 +12,7 @@
import {displayPerson, isLike} from 'src/util/nostr'
import {timedelta, now} from 'src/util/misc'
import {keys, user, pool, getRelays} from 'src/agent'
import {modal, toast, settings, alerts, messages} from "src/app"
import {modal, toast, settings, logUsage, alerts, messages} from "src/app"
import {routes} from "src/app/ui"
import Anchor from 'src/partials/Anchor.svelte'
import Spinner from 'src/partials/Spinner.svelte'
@ -105,9 +105,21 @@
}
})
const unsubHistory = globalHistory.listen(({location}) => {
if (!location.hash) {
// Remove identifying information, e.g. pubkeys, event ids, etc
const name = location.pathname.slice(1)
.replace(/(npub|nprofile|note|nevent)[^\/]+/g, (_, m) => `<${m}>`)
logUsage(btoa(['page', name].join(':')))
}
})
const unsubModal = modal.subscribe($modal => {
// Keep scroll position on body, but don't allow scrolling
if ($modal) {
logUsage(btoa(['modal', $modal.type].join(':')))
// This is not idempotent, so don't duplicate it
if (document.body.style.position !== 'fixed') {
scrollY = window.scrollY
@ -123,6 +135,7 @@
return () => {
clearInterval(interval)
unsubHistory()
unsubModal()
}
})
@ -260,7 +273,7 @@
<a
class="rounded-full bg-accent color-white w-16 h-16 flex justify-center
items-center border border-dark shadow-2xl cursor-pointer"
on:click={() => modal.set({form: 'note/create'})}>
on:click={() => modal.set({type: 'note/create'})}>
<span class="fa-sold fa-plus fa-2xl" />
</a>
</div>
@ -268,27 +281,24 @@
{#if $modal}
<Modal onEscape={closeModal}>
{#if $modal.note}
{#if $modal.type === 'note/detail'}
{#key $modal.note.id}
<NoteDetail {...$modal} />
{/key}
{:else if $modal.form === 'note/create'}
{:else if $modal.type === 'note/create'}
<NoteCreate />
{:else if $modal.form === 'relay'}
{:else if $modal.type === 'relay/add'}
<AddRelay />
{:else if $modal.form === 'signUp'}
{:else if $modal.type === 'signUp'}
<SignUp />
{:else if $modal.form === 'room/edit'}
{:else if $modal.type === 'room/edit'}
<ChatEdit {...$modal} />
{:else if $modal.form === 'privkeyLogin'}
{:else if $modal.type === 'login/privkey'}
<PrivKeyLogin />
{:else if $modal.form === 'pubkeyLogin'}
{:else if $modal.type === 'login/pubkey'}
<PubKeyLogin />
{:else if $modal.form === 'person/settings'}
{:else if $modal.type === 'person/settings'}
<PersonSettings />
{:else if $modal.message}
<p class="text-white text-center py-12 pb-8">{$modal.message}</p>
<Spinner />
{/if}
</Modal>
{/if}

View File

@ -8,10 +8,10 @@ import {personKinds, Tags, roomAttrs} from 'src/util/nostr'
export const db = new Dexie('agent/data/db')
db.version(12).stores({
db.version(13).stores({
relays: '++url, name',
alerts: '++id, created_at',
messages: '++id, pubkey',
messages: '++id, pubkey, recipient',
people: '++pubkey',
rooms: '++id, joined',
})
@ -157,7 +157,9 @@ const processRoomEvents = async events => {
}
const processMessages = async events => {
const messages = ensurePlural(events).filter(e => e.kind === 4)
const messages = ensurePlural(events)
.filter(e => e.kind === 4)
.map(e => ({...e, recipient: Tags.from(e).type("p").values().first()}))
if (messages.length > 0) {
await db.messages.bulkPut(messages)

View File

@ -16,10 +16,12 @@ const setPrivateKey = _privkey => {
}
const setPublicKey = _pubkey => {
signingFunction = async event => {
const {sig} = await window.nostr.signEvent(event)
if (window.nostr) {
signingFunction = async event => {
const {sig} = await window.nostr.signEvent(event)
return sig
return sig
}
}
pubkey.set(_pubkey)
@ -59,6 +61,7 @@ const getCrypt = () => {
? nip04.decrypt($privkey, pubkey, message)
: await window.nostr.nip04.decrypt(pubkey, message)
} catch (e) {
console.error(e)
return `<Failed to decrypt message: ${e}>`
}
},

View File

@ -6,13 +6,13 @@ import {renderContent} from 'src/util/html'
import {Tags, displayPerson, findReplyId} from 'src/util/nostr'
import {user, people, getPerson, getRelays, keys} from 'src/agent'
import defaults from 'src/agent/defaults'
import {toast, routes, modal, settings} from 'src/app/ui'
import {toast, routes, modal, settings, logUsage} from 'src/app/ui'
import cmd from 'src/app/cmd'
import alerts from 'src/app/alerts'
import messages from 'src/app/messages'
import loaders from 'src/app/loaders'
export {toast, modal, settings, alerts, messages}
export {toast, modal, settings, alerts, messages, logUsage}
export const login = async ({privkey, pubkey}, usingExtension = false) => {
if (privkey) {

View File

@ -1,4 +1,5 @@
import {prop} from "ramda"
import Bugsnag from "@bugsnag/js"
import {prop, nthArg} from "ramda"
import {uuid} from "hurdak/lib/hurdak"
import {navigate} from "svelte-routing"
import {nip19} from 'nostr-tools'
@ -54,6 +55,32 @@ export const modal = {
// Settings, alerts, etc
export const settings = synced("coracle/settings", {
reportAnalytics: true,
showLinkPreviews: true,
dufflepudUrl: import.meta.env.VITE_DUFFLEPUD_URL,
})
// Wait for bugsnag to be started in main
setTimeout(() => {
Bugsnag.addOnError(event => {
if (window.location.host.startswith('localhost')) {
return false
}
if (!get(settings).reportAnalytics) {
return false
}
return true
})
})
const session = Math.random().toString().slice(2)
export const logUsage = name => {
const {dufflepudUrl, reportAnalytics} = get(settings)
if (reportAnalytics) {
fetch(`${dufflepudUrl}/usage/${session}/${name}`, {method: 'post' })
}
}

View File

@ -1,13 +1,14 @@
import 'src/app.css'
import Bugsnag from "@bugsnag/js"
import App from 'src/App.svelte'
// Annoying global always fails silently. Figure out an eslint rule instead
window.find = null
Bugsnag.start({apiKey: "2ea412feabfe14dc9849c6f0b4fa7003"})
import App from 'src/App.svelte'
// Annoying global always fails silently. TODO: figure out an eslint rule instead
window.find = null
const app = new App({
target: document.getElementById('app')
})

View File

@ -1,9 +1,10 @@
<script>
import cx from 'classnames'
import {onMount} from 'svelte'
import {fly} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {prop, path as getPath, reverse, uniqBy, sortBy, last} from 'ramda'
import {formatTimestamp, createScroller, Cursor} from 'src/util/misc'
import {formatTimestamp, sleep, createScroller, Cursor} from 'src/util/misc'
import Badge from 'src/partials/Badge.svelte'
import Spinner from 'src/partials/Spinner.svelte'
import {user, getPerson} from 'src/agent'
@ -16,9 +17,12 @@
export let listenForMessages
export let sendMessage
export let editRoom = null
export let type
console.log(editRoom)
let textarea
let messages = []
let loading = sleep(10_000)
let annotatedMessages = []
let showNewMessages = false
let cursor = new Cursor()
@ -62,6 +66,7 @@
const sub = listenForMessages(
newMessages => stickToBottom('smooth', () => {
loading = sleep(10_000)
messages = messages.concat(newMessages)
})
)
@ -74,6 +79,7 @@
cursor.onChunk(events)
stickToBottom('auto', () => {
loading = sleep(10_000)
messages = events.concat(messages)
})
}
@ -115,39 +121,47 @@
<div class="flex gap-4 h-full">
<div class="relative w-full">
<div class="flex flex-col py-32">
<ul class="p-4 max-h-full flex-grow flex flex-col-reverse" name="messages">
<div class="flex flex-col py-32 h-full">
<ul class="p-4 h-full flex-grow flex flex-col-reverse justify-start" name="messages">
{#each annotatedMessages as m (m.id)}
<li in:fly={{y: 20}} class="py-1 flex flex-col gap-2">
{#if m.showPerson}
{#if type === 'chat' && m.showPerson}
<div class="flex gap-4 items-center justify-between">
<Badge person={m.person} />
<p class="text-sm text-light">{formatTimestamp(m.created_at)}</p>
</div>
{/if}
<div class="ml-6 overflow-hidden text-ellipsis">
<div class={cx("overflow-hidden text-ellipsis", {
'ml-6': type === 'chat',
'rounded-2xl py-2 px-4': type === 'dm',
'ml-12 bg-light text-black rounded-br-none': type === 'dm' && m.person.pubkey === $user.pubkey,
'mr-12 bg-dark rounded-bl-none': type === 'dm' && m.person.pubkey !== $user.pubkey,
})}>
{@html render(m, {showEntire: true})}
</div>
</li>
{/each}
{#await loading}
<Spinner>Looking for messages...</Spinner>
<div class="h-48" />
{/await}
</ul>
</div>
<div class="fixed z-10 top-0 pt-20 w-full p-4 border-b border-solid border-medium bg-dark flex gap-4">
<div
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({picture})" />
<div class="w-full">
<div class="flex items-center justify-between w-full">
<div class="text-lg font-bold">{name || ''}</div>
{#if editRoom}
<small class="cursor-pointer" on:click={editRoom}>
<i class="fa-solid fa-edit" /> Edit
</small>
{/if}
<div class="fixed z-10 top-16 w-full lg:-ml-56 lg:pl-56 border-b border-solid border-medium bg-dark">
<div class="p-4 flex gap-4">
<div
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({picture})" />
<div class="w-full">
<div class="flex items-center justify-between w-full">
<div class="text-lg font-bold">{name || ''}</div>
{#if editRoom}
<small class="cursor-pointer" on:click={editRoom}>
<i class="fa-solid fa-edit" /> Edit
</small>
{/if}
</div>
<div>{about || ''}</div>
</div>
<div>{about || ''}</div>
</div>
</div>
<div class="fixed z-10 bottom-0 w-full flex bg-medium border-medium border-t border-solid border-dark">

View File

@ -27,7 +27,7 @@
<div
class="py-2 px-3 flex flex-col gap-2 text-white cursor-pointer transition-all
border border-solid border-black hover:border-medium hover:bg-dark"
on:click={() => modal.set({note})}>
on:click={() => modal.set({type: 'note', note})}>
<div class="flex gap-2 items-center justify-between relative">
<span class="cursor-pointer" on:click={openPopover}>
{quantify(note.people.length, 'person', 'people')} liked your note.

View File

@ -2,7 +2,7 @@
import cx from 'classnames'
import extractUrls from 'extract-urls'
import {nip19} from 'nostr-tools'
import {whereEq, uniq, pluck, reject, propEq, find} from 'ramda'
import {whereEq, without, uniq, pluck, reject, propEq, find} from 'ramda'
import {slide} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {quantify} from 'hurdak/lib/hurdak'
@ -24,7 +24,7 @@
export let invertColors = false
let reply = null
let replyMentions = Tags.from(note).type("p").values().all()
let replyMentions = without([$user.pubkey], Tags.from(note).type("p").values().all())
const links = $settings.showLinkPreviews ? extractUrls(note.content) || [] : null
const showEntire = anchorId === note.id
@ -45,7 +45,7 @@
const onClick = e => {
if (!['I'].includes(e.target.tagName) && !e.target.closest('a')) {
modal.set({note, relays})
modal.set({type: 'note', note, relays})
}
}
@ -53,7 +53,7 @@
const [id, url] = findReply(note).slice(1)
const relays = getEventRelays(note).concat({url})
modal.set({note: {id}, relays})
modal.set({type: 'note', note: {id}, relays})
}
const react = async content => {
@ -93,7 +93,7 @@
}
const removeMention = pubkey => {
replyMentions = reject(p => p === pubkey, replyMentions)
replyMentions = without([pubkey], replyMentions)
}
const resetReply = () => {
@ -195,6 +195,9 @@
</div>
{#if replyMentions.length > 0}
<div class="text-white text-sm p-2 rounded-b border-t-0 border border-solid border-medium">
<div class="inline-block border-r border-solid border-medium pl-1 pr-3 mr-2">
<i class="fa fa-at" />
</div>
{#each replyMentions as p}
<div class="inline-block py-1 px-2 mr-1 mb-2 rounded-full border border-solid border-light">
<i class="fa fa-times cursor-pointer" on:click|stopPropagation={() => removeMention(p)} />

View File

@ -21,7 +21,7 @@
<div class="flex flex-grow flex-col justify-start gap-2">
<div class="flex flex-grow items-center justify-between gap-2">
<div class="flex gap-2 items-center">
{#if room.type === 'private'}
{#if room.type === 'npub'}
<i class="fa fa-lock text-light" />
<h2 class="text-lg">{displayPerson(room)}</h2>
{:else}
@ -29,7 +29,7 @@
<h2 class="text-lg">{room.name}</h2>
{/if}
</div>
{#if room.type === 'public'}
{#if room.type === 'note'}
{#if joined}
<Anchor type="button" class="flex items-center gap-2" on:click={e => { e.stopPropagation(); leaveRoom(room.id) }}>
<i class="fa fa-right-from-bracket" />

View File

@ -75,7 +75,7 @@
<i class="fa fa-server fa-lg" />
<h2 class="staatliches text-2xl">Your rooms</h2>
</div>
<Anchor type="button-accent" on:click={() => modal.set({form: 'room/edit'})}>
<Anchor type="button-accent" on:click={() => modal.set({type: 'room/edit'})}>
<i class="fa-solid fa-plus" /> Create Room
</Anchor>
</div>
@ -103,4 +103,6 @@
Showing {Math.min(50, roomsCount)} of {roomsCount} known rooms
</small>
</Content>
{:else}
<Spinner />
{/if}

View File

@ -39,7 +39,7 @@
}
const editRoom = () => {
modal.set({form: 'room/edit', room: $room})
modal.set({type: 'room/edit', room: $room})
}
const sendMessage = content =>
@ -47,6 +47,7 @@
</script>
<Channel
type="chat"
name={$room?.name}
about={$room?.about}
picture={$room?.picture}

View File

@ -11,16 +11,16 @@
if (window.nostr) {
await login({pubkey: await window.nostr.getPublicKey()}, true)
} else {
modal.set({form: 'privkeyLogin'})
modal.set({type: 'login/privkey'})
}
}
const signUp = () => {
modal.set({form: 'signUp'})
modal.set({type: 'signUp'})
}
const pubkeyLogIn = () => {
modal.set({form: 'pubkeyLogin'})
modal.set({type: 'login/pubkey'})
}
</script>

View File

@ -1,11 +1,11 @@
<script>
import {liveQuery} from 'dexie'
import {nip19} from 'nostr-tools'
import {assoc, propEq, mergeRight} from 'ramda'
import {now, batch} from 'src/util/misc'
import {sortBy, pluck} from 'ramda'
import {personKinds} from 'src/util/nostr'
import {batch} from 'src/util/misc'
import Channel from 'src/partials/Channel.svelte'
import {getRelays, user, db, listen, load, keys} from 'src/agent'
import {getRelays, user, db, listen, keys} from 'src/agent'
import cmd from 'src/app/cmd'
export let entity
@ -13,41 +13,52 @@
let crypt = keys.getCrypt()
let {data: pubkey} = nip19.decode(entity)
let person = liveQuery(() => db.people.get(pubkey))
let filters = [
{kinds: [4], authors: [$user.pubkey], '#p': [pubkey]},
{kinds: [4], authors: [pubkey], '#p': [$user.pubkey]}]
const decryptMessages = async messages => {
const decryptMessages = async events => {
// Gotta do it in serial because of extension limitations
for (const message of messages) {
message.content = await crypt.decrypt(message.pubkey, message.content)
console.log(message.content)
for (const event of events) {
const key = event.pubkey === pubkey ? pubkey : event.recipient
event.content = await crypt.decrypt(key, event.content)
}
return messages
return events
}
const listenForMessages = cb => listen(
getRelays(),
[{kinds: personKinds, authors: [pubkey]},
...filters.map(assoc('since', now()))],
batch(300, events => {
return cb(decryptMessages(events.filter(propEq('kind', 4))))
{kinds: [4], authors: [$user.pubkey], '#p': [pubkey]},
{kinds: [4], authors: [pubkey], '#p': [$user.pubkey]}],
batch(300, async events => {
// Reload from db since we annotate messages there
const messageIds = pluck('id', events.filter(e => e.kind === 4))
const messages = await db.messages.where('id').anyOf(messageIds).toArray()
cb(await decryptMessages(messages))
})
)
const loadMessages = async ({until, limit}) => {
const messages = await load(getRelays(), filters.map(mergeRight({until, limit})))
const fromThem = await db.messages.where('pubkey').equals(pubkey).toArray()
const toThem = await db.messages.where('recipient').equals(pubkey).toArray()
const events = fromThem.concat(toThem).filter(e => e.created_at < until)
const messages = sortBy(e => -e.created_at, events).slice(0, limit)
return await decryptMessages(messages)
}
const sendMessage = async content => {
const cyphertext = await crypt.encrypt(pubkey, content)
const event = await cmd.createDirectMessage(getRelays(), pubkey, cyphertext)
const sendMessage = content =>
cmd.createDirectMessage(getRelays(), pubkey, content)
// Return unencrypted content so we can display it immediately
return {...event, content}
}
</script>
<Channel
type="dm"
name={$person?.name}
about={$person?.about}
picture={$person?.picture}

View File

@ -9,6 +9,7 @@
import {displayPerson} from 'src/util/nostr'
import Tabs from "src/partials/Tabs.svelte"
import Content from "src/partials/Content.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Button from "src/partials/Button.svelte"
import Spinner from "src/partials/Spinner.svelte"
import Notes from "src/views/person/Notes.svelte"
@ -76,7 +77,7 @@
}
const openAdvanced = () => {
modal.set({form: 'person/settings', person})
modal.set({type: 'person/settings', person})
}
</script>
@ -93,8 +94,8 @@
<div
class="overflow-hidden w-32 h-32 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({person.picture})" />
<div class="flex flex-col gap-4">
<div class="flex items-start gap-4">
<div class="flex flex-col gap-4 flex-grow">
<div class="flex justify-between items-center gap-4">
<div class="flex-grow flex flex-col gap-2">
<div class="flex items-center gap-2">
<h1 class="text-2xl">{displayPerson(person)}</h1>
@ -108,21 +109,21 @@
</div>
<div class="whitespace-nowrap flex gap-4 items-center">
{#if $user?.pubkey === pubkey && keys.canSign()}
<a href="/profile" class="cursor-pointer text-sm">
<i class="fa-solid fa-edit" /> Edit
</a>
{/if}
{#if $user && $user.pubkey !== pubkey}
<i class="fa-solid fa-sliders cursor-pointer" on:click={openAdvanced} />
{/if}
{#if $user?.petnames && keys.canSign()}
<div class="flex flex-col items-end gap-2">
<Anchor type="button" href="/profile">
<i class="fa-solid fa-edit" /> Edit profile
</Anchor>
{:else if $user && keys.canSign()}
<Anchor type="button" on:click={openAdvanced}>
<i class="fa-solid fa-sliders" />
</Anchor>
<Anchor type="button" href={`/messages/${npub}`}>
<i class="fa-solid fa-envelope" />
</Anchor>
{#if following}
<Button on:click={unfollow}>Unfollow</Button>
{:else}
<Button on:click={follow}>Follow</Button>
{/if}
</div>
{/if}
</div>
</div>

View File

@ -73,7 +73,7 @@
<i class="fa fa-server fa-lg" />
<h2 class="staatliches text-2xl">Your relays</h2>
</div>
<Anchor type="button" on:click={() => modal.set({form: 'relay', url: q})}>
<Anchor type="button" on:click={() => modal.set({type: 'relay/add', url: q})}>
<i class="fa-solid fa-plus" /> Add Relay
</Anchor>
</div>

View File

@ -56,6 +56,16 @@
hosting images and loading link previews.
</p>
</div>
<div class="flex flex-col gap-1">
<div class="flex gap-2 items-center">
<strong>Report Errors and Analytics</strong>
<Toggle bind:value={values.reportAnalytics} />
</div>
<p class="text-sm text-light">
Keep this enabled if you would like the Coracle developers to be able to
know what features are used, and to diagnose and fix bugs.
</p>
</div>
<Button type="submit" class="text-center">Save</Button>
</div>
</Content>

View File

@ -1,14 +1,13 @@
<script>
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 Content from "src/partials/Content.svelte"
import Textarea from "src/partials/Textarea.svelte"
import Button from "src/partials/Button.svelte"
import {getRelays, db} from 'src/agent'
import {toast} from "src/app"
import {toast, modal} from "src/app"
import cmd from "src/app/cmd"
export let room = {}
@ -34,7 +33,7 @@
if (!room.name) {
toast.show("error", "Please enter a name for your room.")
} else {
const event = room.id
room.id
? await cmd.updateRoom(getRelays(), room)
: await cmd.createRoom(getRelays(), room)
@ -42,7 +41,7 @@
toast.show("info", `Your room has been ${room.id ? 'updated' : 'created'}!`)
navigate(`/chat/${room.id || event.id}`)
modal.set(null)
}
}
</script>

View File

@ -9,8 +9,9 @@
import {modal} from 'src/app'
import cmd from 'src/app/cmd'
const muffle = $user.muffle || []
const muffleOptions = ['Never', 'Sometimes', 'Often', 'Always']
const muffleValue = parseFloat(first($user.muffle.filter(t => t[1] === $modal.person.pubkey).map(last)) || 1)
const muffleValue = parseFloat(first(muffle.filter(t => t[1] === $modal.person.pubkey).map(last)) || 1)
const values = {
// Scale up to integers for each choice we have
@ -22,7 +23,7 @@
// Scale back down to a decimal based on string value
const muffleValue = muffleOptions.indexOf(values.muffle) / 3
const muffleTags = $user.muffle
const muffleTags = muffle
.filter(t => t[1] !== $modal.person.pubkey)
.concat([["p", $modal.person.pubkey, muffleValue.toString()]])
.filter(t => last(t) !== "1")