Compare commits
5 Commits
f01b5f8faa
...
03583e5a52
| Author | SHA1 | Date | |
|---|---|---|---|
|
03583e5a52
|
|||
|
b9f64f30e1
|
|||
|
4bd5c4bf2a
|
|||
|
f875fc1877
|
|||
|
2268a607d5
|
43
app/components/modal.gjs
Normal file
43
app/components/modal.gjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import Icon from './icon';
|
||||||
|
|
||||||
|
export default class Modal extends Component {
|
||||||
|
@action
|
||||||
|
stopProp(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
close() {
|
||||||
|
if (this.args.onClose) {
|
||||||
|
this.args.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
{{on "click" this.close}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="modal-content"
|
||||||
|
role="document"
|
||||||
|
tabindex="0"
|
||||||
|
{{on "click" this.stopProp}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close-modal-btn btn-text"
|
||||||
|
{{on "click" this.close}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{24}} />
|
||||||
|
</button>
|
||||||
|
{{yield}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { getSocialInfo } from '../utils/social-links';
|
|||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import PlaceEditForm from './place-edit-form';
|
import PlaceEditForm from './place-edit-form';
|
||||||
import PlaceListsManager from './place-lists-manager';
|
import PlaceListsManager from './place-lists-manager';
|
||||||
|
import PlacePhotoUpload from './place-photo-upload';
|
||||||
|
import Modal from './modal';
|
||||||
|
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
@@ -17,6 +19,20 @@ export default class PlaceDetails extends Component {
|
|||||||
@service storage;
|
@service storage;
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
@tracked showLists = false;
|
@tracked showLists = false;
|
||||||
|
@tracked isPhotoUploadModalOpen = false;
|
||||||
|
|
||||||
|
@action
|
||||||
|
openPhotoUploadModal(e) {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
this.isPhotoUploadModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closePhotoUploadModal() {
|
||||||
|
this.isPhotoUploadModalOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
get isSaved() {
|
get isSaved() {
|
||||||
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
|
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
|
||||||
@@ -499,7 +515,24 @@ export default class PlaceDetails extends Component {
|
|||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.osmUrl}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="camera" />
|
||||||
|
<span>
|
||||||
|
<a href="#" {{on "click" this.openPhotoUploadModal}}>
|
||||||
|
Add a photo
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.isPhotoUploadModalOpen}}
|
||||||
|
<Modal @onClose={{this.closePhotoUploadModal}}>
|
||||||
|
<PlacePhotoUpload @place={{this.saveablePlace}} />
|
||||||
|
</Modal>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
168
app/components/place-photo-upload.gjs
Normal file
168
app/components/place-photo-upload.gjs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { EventFactory } from 'applesauce-core';
|
||||||
|
import Geohash from 'latlon-geohash';
|
||||||
|
|
||||||
|
export default class PlacePhotoUpload extends Component {
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrRelay;
|
||||||
|
|
||||||
|
@tracked photoUrl = '';
|
||||||
|
@tracked status = '';
|
||||||
|
@tracked error = '';
|
||||||
|
|
||||||
|
get place() {
|
||||||
|
return this.args.place || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
return this.place.title || 'this place';
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async login() {
|
||||||
|
try {
|
||||||
|
this.error = '';
|
||||||
|
await this.nostrAuth.login();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async uploadPhoto(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.error = '';
|
||||||
|
this.status = 'Uploading...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mock upload
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
this.photoUrl =
|
||||||
|
'https://dummyimage.com/600x400/000/fff.jpg&text=Mock+Place+Photo';
|
||||||
|
this.status = 'Photo uploaded! Ready to publish.';
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Upload failed: ' + e.message;
|
||||||
|
this.status = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async publish() {
|
||||||
|
if (!this.nostrAuth.isConnected) {
|
||||||
|
this.error = 'You must connect Nostr first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.photoUrl) {
|
||||||
|
this.error = 'Please upload a photo.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { osmId, lat, lon } = this.place;
|
||||||
|
const osmType = this.place.osmType || 'node';
|
||||||
|
|
||||||
|
if (!osmId) {
|
||||||
|
this.error = 'This place does not have a valid OSM ID.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = 'Publishing event...';
|
||||||
|
this.error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
|
|
||||||
|
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
||||||
|
|
||||||
|
if (lat && lon) {
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 4)]);
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 6)]);
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 7)]);
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 9)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push([
|
||||||
|
'imeta',
|
||||||
|
`url ${this.photoUrl}`,
|
||||||
|
'm image/jpeg',
|
||||||
|
'dim 600x400',
|
||||||
|
'alt A photo of a place',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// NIP-XX draft Place Photo event
|
||||||
|
const template = {
|
||||||
|
kind: 360,
|
||||||
|
content: '',
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure created_at is present before signing
|
||||||
|
if (!template.created_at) {
|
||||||
|
template.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await factory.sign(template);
|
||||||
|
await this.nostrRelay.publish(event);
|
||||||
|
|
||||||
|
this.status = 'Published successfully!';
|
||||||
|
// Reset form
|
||||||
|
this.photoUrl = '';
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Failed to publish: ' + e.message;
|
||||||
|
this.status = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="place-photo-upload">
|
||||||
|
<h2>Add Photo for {{this.title}}</h2>
|
||||||
|
|
||||||
|
{{#if this.error}}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
{{this.error}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.status}}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
{{this.status}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.nostrAuth.isConnected}}
|
||||||
|
<div class="connected-status">
|
||||||
|
<strong>Connected:</strong>
|
||||||
|
{{this.nostrAuth.pubkey}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form {{on "submit" this.uploadPhoto}}>
|
||||||
|
{{#if this.photoUrl}}
|
||||||
|
<div class="preview-group">
|
||||||
|
<p>Photo Preview:</p>
|
||||||
|
<img src={{this.photoUrl}} alt="Preview" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
{{on "click" this.publish}}
|
||||||
|
>
|
||||||
|
Publish Event (kind: 360)
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button type="submit" class="btn btn-secondary">
|
||||||
|
Mock Upload Photo
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<button type="button" class="btn btn-primary" {{on "click" this.login}}>
|
||||||
|
Connect Nostr Extension
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ export default class UserMenuComponent extends Component {
|
|||||||
@service storage;
|
@service storage;
|
||||||
@service osmAuth;
|
@service osmAuth;
|
||||||
|
|
||||||
|
@service nostrAuth;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
connectRS() {
|
connectRS() {
|
||||||
this.args.onClose();
|
this.args.onClose();
|
||||||
@@ -30,6 +32,21 @@ export default class UserMenuComponent extends Component {
|
|||||||
this.osmAuth.logout();
|
this.osmAuth.logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async connectNostr() {
|
||||||
|
try {
|
||||||
|
await this.nostrAuth.login();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
disconnectNostr() {
|
||||||
|
this.nostrAuth.logout();
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="user-menu-popover">
|
<div class="user-menu-popover">
|
||||||
<ul class="account-list">
|
<ul class="account-list">
|
||||||
@@ -91,15 +108,34 @@ export default class UserMenuComponent extends Component {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="account-item disabled">
|
<li class="account-item">
|
||||||
<div class="account-header">
|
<div class="account-header">
|
||||||
<div class="account-info">
|
<div class="account-info">
|
||||||
<Icon @name="zap" @size={{18}} />
|
<Icon @name="zap" @size={{18}} />
|
||||||
<span>Nostr</span>
|
<span>Nostr</span>
|
||||||
</div>
|
</div>
|
||||||
|
{{#if this.nostrAuth.isConnected}}
|
||||||
|
<button
|
||||||
|
class="btn-text text-danger"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.disconnectNostr}}
|
||||||
|
>Disconnect</button>
|
||||||
|
{{else}}
|
||||||
|
<button
|
||||||
|
class="btn-text text-primary"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.connectNostr}}
|
||||||
|
>Connect</button>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
<div class="account-status">
|
<div class="account-status">
|
||||||
Coming soon
|
{{#if this.nostrAuth.isConnected}}
|
||||||
|
<strong title={{this.nostrAuth.pubkey}}>
|
||||||
|
{{this.nostrAuth.pubkey}}
|
||||||
|
</strong>
|
||||||
|
{{else}}
|
||||||
|
Not connected
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
78
app/services/nostr-auth.js
Normal file
78
app/services/nostr-auth.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { ExtensionSigner } from 'applesauce-signers';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'marco:nostr_pubkey';
|
||||||
|
|
||||||
|
export default class NostrAuthService extends Service {
|
||||||
|
@tracked pubkey = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
this.pubkey = saved;
|
||||||
|
this._verifyPubkey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _verifyPubkey() {
|
||||||
|
if (typeof window.nostr === 'undefined') {
|
||||||
|
this.logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signer = new ExtensionSigner();
|
||||||
|
const extensionPubkey = await signer.getPublicKey();
|
||||||
|
|
||||||
|
if (extensionPubkey !== this.pubkey) {
|
||||||
|
this.pubkey = extensionPubkey;
|
||||||
|
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to verify nostr pubkey, logging out', e);
|
||||||
|
this.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected() {
|
||||||
|
return !!this.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signer() {
|
||||||
|
if (typeof window.nostr !== 'undefined') {
|
||||||
|
return new ExtensionSigner();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login() {
|
||||||
|
if (typeof window.nostr === 'undefined') {
|
||||||
|
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.pubkey = await this.signer.getPublicKey();
|
||||||
|
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||||
|
return this.pubkey;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get public key from extension:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async signEvent(event) {
|
||||||
|
if (!this.signer) {
|
||||||
|
throw new Error(
|
||||||
|
'Not connected or extension missing. Please connect Nostr again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await this.signer.signEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.pubkey = null;
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/services/nostr-relay.js
Normal file
25
app/services/nostr-relay.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
import { RelayPool } from 'applesauce-relay';
|
||||||
|
|
||||||
|
export default class NostrRelayService extends Service {
|
||||||
|
pool = new RelayPool();
|
||||||
|
|
||||||
|
// For Phase 1, we hardcode the local relay
|
||||||
|
relays = ['ws://127.0.0.1:7777'];
|
||||||
|
|
||||||
|
async publish(event) {
|
||||||
|
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
||||||
|
// and automatically handles reconnecting and retrying.
|
||||||
|
const responses = await this.pool.publish(this.relays, event);
|
||||||
|
|
||||||
|
// Check if at least one relay accepted the event
|
||||||
|
const success = responses.some((res) => res.ok);
|
||||||
|
if (!success) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,6 +252,9 @@ body {
|
|||||||
color: #898989;
|
color: #898989;
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
margin-left: calc(18px + 0.75rem);
|
margin-left: calc(18px + 0.75rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-status strong {
|
.account-status strong {
|
||||||
@@ -1374,3 +1377,72 @@ button.create-place {
|
|||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 50%);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 90vw;
|
||||||
|
width: 450px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Place Photo Upload */
|
||||||
|
.place-photo-upload h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #fee;
|
||||||
|
color: #c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background: #eef;
|
||||||
|
color: #00c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected-status {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #080;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-group p {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-group img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ Identifies the exact place the media depicts using an external identifier (as de
|
|||||||
```json
|
```json
|
||||||
["i", "osm:node:123456"]
|
["i", "osm:node:123456"]
|
||||||
```
|
```
|
||||||
* For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`.
|
|
||||||
|
- For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`.
|
||||||
|
|
||||||
#### 2. `g` — Geohash
|
#### 2. `g` — Geohash
|
||||||
|
|
||||||
@@ -61,9 +62,9 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
|
|||||||
|
|
||||||
### Optional Tags
|
### Optional Tags
|
||||||
|
|
||||||
* `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
|
- `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
|
||||||
* `content-warning`: If the media contains NSFW or sensitive imagery.
|
- `content-warning`: If the media contains NSFW or sensitive imagery.
|
||||||
* `published_at`: Unix timestamp of when the photo was originally taken or published.
|
- `published_at`: Unix timestamp of when the photo was originally taken or published.
|
||||||
|
|
||||||
## Example Event
|
## Example Event
|
||||||
|
|
||||||
@@ -80,7 +81,8 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
|
|||||||
["g", "xn0m7h"],
|
["g", "xn0m7h"],
|
||||||
["g", "xn0m7hwq"],
|
["g", "xn0m7hwq"],
|
||||||
|
|
||||||
["imeta",
|
[
|
||||||
|
"imeta",
|
||||||
"url https://example.com/ramen.jpg",
|
"url https://example.com/ramen.jpg",
|
||||||
"m image/jpeg",
|
"m image/jpeg",
|
||||||
"dim 1080x1080",
|
"dim 1080x1080",
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ This NIP defines a standardized event format for decentralized place reviews usi
|
|||||||
|
|
||||||
The design prioritizes:
|
The design prioritizes:
|
||||||
|
|
||||||
* Small event size
|
- Small event size
|
||||||
* Interoperability across clients
|
- Interoperability across clients
|
||||||
* Flexibility for different place types
|
- Flexibility for different place types
|
||||||
* Efficient geospatial querying using geohashes
|
- Efficient geospatial querying using geohashes
|
||||||
|
|
||||||
## Event Kind
|
## Event Kind
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ Additional tags MAY be included by clients but are not defined by this specifica
|
|||||||
|
|
||||||
This NIP reuses and builds upon existing Nostr tag conventions:
|
This NIP reuses and builds upon existing Nostr tag conventions:
|
||||||
|
|
||||||
* `i` tag: see NIP-73 (External Content Identifiers)
|
- `i` tag: see NIP-73 (External Content Identifiers)
|
||||||
* `g` tag: geohash-based geotagging (community conventions)
|
- `g` tag: geohash-based geotagging (community conventions)
|
||||||
|
|
||||||
Where conflicts arise, this NIP specifies the behavior for review events.
|
Where conflicts arise, this NIP specifies the behavior for review events.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Identifies the reviewed place using an external identifier. OpenStreetMap data i
|
|||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
* For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`
|
- For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
@@ -57,15 +57,15 @@ Geohash tags are used for spatial indexing and discovery.
|
|||||||
|
|
||||||
##### Requirements
|
##### Requirements
|
||||||
|
|
||||||
* Clients MUST include at least one high-precision geohash (length ≥ 9)
|
- Clients MUST include at least one high-precision geohash (length ≥ 9)
|
||||||
|
|
||||||
##### Recommendations
|
##### Recommendations
|
||||||
|
|
||||||
Clients SHOULD include geohashes at the following resolutions:
|
Clients SHOULD include geohashes at the following resolutions:
|
||||||
|
|
||||||
* length 4 — coarse (city-scale discovery)
|
- length 4 — coarse (city-scale discovery)
|
||||||
* length 6 — medium (default query level, ~1 km)
|
- length 6 — medium (default query level, ~1 km)
|
||||||
* length 7 — fine (neighborhood, ~150 m)
|
- length 7 — fine (neighborhood, ~150 m)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -80,10 +80,10 @@ Example:
|
|||||||
|
|
||||||
Geospatial queries are performed using the `g` tag.
|
Geospatial queries are performed using the `g` tag.
|
||||||
|
|
||||||
* Clients SHOULD query using a single geohash precision level per request
|
- Clients SHOULD query using a single geohash precision level per request
|
||||||
* Clients MAY include multiple geohash values in a filter to cover a bounding box
|
- Clients MAY include multiple geohash values in a filter to cover a bounding box
|
||||||
* Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30)
|
- Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30)
|
||||||
* Clients MAY reduce precision or split queries when necessary
|
- Clients MAY reduce precision or split queries when necessary
|
||||||
|
|
||||||
Note: Other queries (e.g. fetching reviews for a specific place) are performed using the `i` tag and are outside the scope of geospatial querying.
|
Note: Other queries (e.g. fetching reviews for a specific place) are performed using the `i` tag and are outside the scope of geospatial querying.
|
||||||
|
|
||||||
@@ -228,22 +228,22 @@ The event `content` MUST be valid JSON matching the following schema.
|
|||||||
|
|
||||||
### Ratings
|
### Ratings
|
||||||
|
|
||||||
* Scores are integers from 1 to 10
|
- Scores are integers from 1 to 10
|
||||||
* `quality` is required and represents the core evaluation of the place
|
- `quality` is required and represents the core evaluation of the place
|
||||||
* Other fields are optional and context-dependent
|
- Other fields are optional and context-dependent
|
||||||
|
|
||||||
### Aspects
|
### Aspects
|
||||||
|
|
||||||
* Free-form keys allow domain-specific ratings
|
- Free-form keys allow domain-specific ratings
|
||||||
* Clients MAY define and interpret aspect keys
|
- Clients MAY define and interpret aspect keys
|
||||||
* Clients SHOULD reuse commonly established aspect keys where possible
|
- Clients SHOULD reuse commonly established aspect keys where possible
|
||||||
|
|
||||||
## Recommendation Signal
|
## Recommendation Signal
|
||||||
|
|
||||||
The `recommend` field represents a binary verdict:
|
The `recommend` field represents a binary verdict:
|
||||||
|
|
||||||
* `true` → user recommends the place
|
- `true` → user recommends the place
|
||||||
* `false` → user does not recommend the place
|
- `false` → user does not recommend the place
|
||||||
|
|
||||||
Clients SHOULD strongly encourage users to provide this value.
|
Clients SHOULD strongly encourage users to provide this value.
|
||||||
|
|
||||||
@@ -251,9 +251,9 @@ Clients SHOULD strongly encourage users to provide this value.
|
|||||||
|
|
||||||
Represents user familiarity with the place:
|
Represents user familiarity with the place:
|
||||||
|
|
||||||
* `low` → first visit or limited exposure
|
- `low` → first visit or limited exposure
|
||||||
* `medium` → occasional visits
|
- `medium` → occasional visits
|
||||||
* `high` → frequent or expert-level familiarity
|
- `high` → frequent or expert-level familiarity
|
||||||
|
|
||||||
Clients MAY use this signal for weighting during aggregation.
|
Clients MAY use this signal for weighting during aggregation.
|
||||||
|
|
||||||
@@ -261,16 +261,16 @@ Clients MAY use this signal for weighting during aggregation.
|
|||||||
|
|
||||||
Optional metadata about the visit.
|
Optional metadata about the visit.
|
||||||
|
|
||||||
* `visited_at` is a Unix timestamp
|
- `visited_at` is a Unix timestamp
|
||||||
* `duration_minutes` represents time spent
|
- `duration_minutes` represents time spent
|
||||||
* `party_size` indicates group size
|
- `party_size` indicates group size
|
||||||
|
|
||||||
## Interoperability
|
## Interoperability
|
||||||
|
|
||||||
This specification defines a content payload only.
|
This specification defines a content payload only.
|
||||||
|
|
||||||
* In Nostr: place identity is conveyed via tags
|
- In Nostr: place identity is conveyed via tags
|
||||||
* In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`)
|
- In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`)
|
||||||
|
|
||||||
Content payloads SHOULD NOT include place identifiers.
|
Content payloads SHOULD NOT include place identifiers.
|
||||||
|
|
||||||
@@ -296,18 +296,18 @@ Provides a human-friendly proxy for confidence without requiring numeric input.
|
|||||||
|
|
||||||
Multiple resolutions balance:
|
Multiple resolutions balance:
|
||||||
|
|
||||||
* efficient querying
|
- efficient querying
|
||||||
* small event size
|
- small event size
|
||||||
* early-stage discoverability
|
- early-stage discoverability
|
||||||
|
|
||||||
## Future Work
|
## Future Work
|
||||||
|
|
||||||
* Standardized aspect vocabularies
|
- Standardized aspect vocabularies
|
||||||
* Reputation and weighting models
|
- Reputation and weighting models
|
||||||
* Indexing/aggregation services
|
- Indexing/aggregation services
|
||||||
* Cross-protocol mappings
|
- Cross-protocol mappings
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
* Clients SHOULD validate all input
|
- Clients SHOULD validate all input
|
||||||
* Malicious or spam reviews may require external moderation or reputation systems
|
- Malicious or spam reviews may require external moderation or reputation systems
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
Your inputs:
|
Your inputs:
|
||||||
|
|
||||||
* many users
|
- many users
|
||||||
* partial ratings
|
- partial ratings
|
||||||
* different priorities
|
- different priorities
|
||||||
|
|
||||||
Your output:
|
Your output:
|
||||||
|
|
||||||
> “Best place *for this user right now*”
|
> “Best place _for this user right now_”
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ normalized_score = (score - 1) / 9
|
|||||||
|
|
||||||
Why:
|
Why:
|
||||||
|
|
||||||
* easier math
|
- easier math
|
||||||
* comparable across aspects
|
- comparable across aspects
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -50,8 +50,8 @@ positive_ratio = positive_votes / total_votes
|
|||||||
|
|
||||||
Use something like a **Wilson score interval** (this is key):
|
Use something like a **Wilson score interval** (this is key):
|
||||||
|
|
||||||
* prevents small-sample abuse
|
- prevents small-sample abuse
|
||||||
* avoids “1 review = #1 place”
|
- avoids “1 review = #1 place”
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,13 +102,12 @@ final_score = Σ (aspect_score × weight)
|
|||||||
|
|
||||||
Filter reviews before scoring:
|
Filter reviews before scoring:
|
||||||
|
|
||||||
* time-based:
|
- time-based:
|
||||||
|
- “last 6 months”
|
||||||
|
|
||||||
* “last 6 months”
|
- context-based:
|
||||||
* context-based:
|
- lunch vs dinner
|
||||||
|
- solo vs group
|
||||||
* lunch vs dinner
|
|
||||||
* solo vs group
|
|
||||||
|
|
||||||
This is something centralized platforms barely do.
|
This is something centralized platforms barely do.
|
||||||
|
|
||||||
@@ -118,9 +117,9 @@ This is something centralized platforms barely do.
|
|||||||
|
|
||||||
Weight reviews by:
|
Weight reviews by:
|
||||||
|
|
||||||
* consistency
|
- consistency
|
||||||
* similarity to user preferences
|
- similarity to user preferences
|
||||||
* past agreement
|
- past agreement
|
||||||
|
|
||||||
This gives you:
|
This gives you:
|
||||||
|
|
||||||
@@ -142,8 +141,8 @@ This gives you:
|
|||||||
|
|
||||||
### Derived:
|
### Derived:
|
||||||
|
|
||||||
* food → high positive ratio (~100%)
|
- food → high positive ratio (~100%)
|
||||||
* service → low (~33%)
|
- service → low (~33%)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -186,7 +185,7 @@ Let clients compute it.
|
|||||||
|
|
||||||
Most reviews will have:
|
Most reviews will have:
|
||||||
|
|
||||||
* 1–3 aspects only
|
- 1–3 aspects only
|
||||||
|
|
||||||
That’s fine.
|
That’s fine.
|
||||||
|
|
||||||
@@ -206,12 +205,12 @@ weight = e^(-λ × age)
|
|||||||
|
|
||||||
Even in nostr:
|
Even in nostr:
|
||||||
|
|
||||||
* spam will happen
|
- spam will happen
|
||||||
|
|
||||||
Mitigation later:
|
Mitigation later:
|
||||||
|
|
||||||
* require minimum interactions
|
- require minimum interactions
|
||||||
* reputation layers
|
- reputation layers
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -233,19 +232,19 @@ We can design:
|
|||||||
|
|
||||||
### A. Query layer
|
### A. Query layer
|
||||||
|
|
||||||
* how clients fetch & merge nostr reviews efficiently
|
- how clients fetch & merge nostr reviews efficiently
|
||||||
|
|
||||||
### B. Anti-spam / trust model
|
### B. Anti-spam / trust model
|
||||||
|
|
||||||
* web-of-trust
|
- web-of-trust
|
||||||
* staking / reputation
|
- staking / reputation
|
||||||
|
|
||||||
### C. OSM integration details
|
### C. OSM integration details
|
||||||
|
|
||||||
* handling duplicates
|
- handling duplicates
|
||||||
* POI identity conflicts
|
- POI identity conflicts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
If I had to pick one next:
|
If I had to pick one next:
|
||||||
👉 **trust/reputation system** — because without it, everything you built *will* get gamed.
|
👉 **trust/reputation system** — because without it, everything you built _will_ get gamed.
|
||||||
|
|||||||
@@ -2,23 +2,23 @@
|
|||||||
|
|
||||||
## A. Core universal aspects
|
## A. Core universal aspects
|
||||||
|
|
||||||
These should work for *any* place:
|
These should work for _any_ place:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
"quality", // core offering (food, repair, exhibits, etc.)
|
"quality", // core offering (food, repair, exhibits, etc.)
|
||||||
"value", // value for money/time
|
"value", // value for money/time
|
||||||
"experience", // comfort, usability, vibe
|
"experience", // comfort, usability, vibe
|
||||||
"accessibility" // ease of access, inclusivity
|
"accessibility" // ease of access, inclusivity
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Why these work
|
### Why these work
|
||||||
|
|
||||||
* **quality** → your “product” abstraction (critical)
|
- **quality** → your “product” abstraction (critical)
|
||||||
* **value** → universally meaningful signal
|
- **value** → universally meaningful signal
|
||||||
* **experience** → captures everything “soft”
|
- **experience** → captures everything “soft”
|
||||||
* **accessibility** → often ignored but high utility
|
- **accessibility** → often ignored but high utility
|
||||||
|
|
||||||
👉 Resist adding more. Every extra “universal” weakens the concept.
|
👉 Resist adding more. Every extra “universal” weakens the concept.
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ Not universal, but widely reusable:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
"service", // human interaction
|
"service", // human interaction
|
||||||
"speed", // waiting time / turnaround
|
"speed", // waiting time / turnaround
|
||||||
"cleanliness",
|
"cleanliness",
|
||||||
"safety",
|
"safety",
|
||||||
"reliability",
|
"reliability",
|
||||||
@@ -41,7 +41,7 @@ Not universal, but widely reusable:
|
|||||||
|
|
||||||
These apply to:
|
These apply to:
|
||||||
|
|
||||||
* restaurants, garages, clinics, parks, etc.
|
- restaurants, garages, clinics, parks, etc.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -69,11 +69,10 @@ Let clients define freely:
|
|||||||
|
|
||||||
To reduce fragmentation:
|
To reduce fragmentation:
|
||||||
|
|
||||||
* publish a **public registry (GitHub repo)**
|
- publish a **public registry (GitHub repo)**
|
||||||
* clients can:
|
- clients can:
|
||||||
|
- suggest additions
|
||||||
* suggest additions
|
- map synonyms
|
||||||
* map synonyms
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -96,6 +95,6 @@ Not required, but useful for aggregation engines.
|
|||||||
|
|
||||||
Map familiarity in UI to:
|
Map familiarity in UI to:
|
||||||
|
|
||||||
* high: “I know this place well”
|
- high: “I know this place well”
|
||||||
* medium: “Been a few times”
|
- medium: “Been a few times”
|
||||||
* low: “First visit”
|
- low: “First visit”
|
||||||
|
|||||||
@@ -103,8 +103,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@waysidemapping/pinhead": "^15.20.0",
|
"@waysidemapping/pinhead": "^15.20.0",
|
||||||
|
"applesauce-core": "^5.2.0",
|
||||||
|
"applesauce-factory": "^4.0.0",
|
||||||
|
"applesauce-relay": "^5.2.0",
|
||||||
|
"applesauce-signers": "^5.2.0",
|
||||||
"ember-concurrency": "^5.2.0",
|
"ember-concurrency": "^5.2.0",
|
||||||
"ember-lifeline": "^7.0.0",
|
"ember-lifeline": "^7.0.0",
|
||||||
"oauth2-pkce": "^2.1.3"
|
"oauth2-pkce": "^2.1.3",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
802
pnpm-lock.yaml
generated
802
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user