grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: server-synced includeLocation preference and privacy settings UI

Add includeLocation preference store, gate auto-detect GPS in create and
story flows, and match privacy settings labels and note with native.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+37 -6
+2 -1
app/lib/components/molecules/StoryCreate.svelte
··· 12 12 import Checkbox from '$lib/components/atoms/Checkbox.svelte' 13 13 import ContentWarningPicker from '$lib/components/atoms/ContentWarningPicker.svelte' 14 14 import { createBskyPost } from '$lib/utils/bsky-post' 15 + import { includeLocation } from '$lib/preferences' 15 16 import { viewer } from '$lib/stores' 16 17 17 18 let { onclose }: { onclose: () => void } = $props() ··· 52 53 53 54 // Auto-suggest location from GPS 54 55 const gps = photo.gps 55 - if (gps) { 56 + if (gps && $includeLocation) { 56 57 reverseGeocode(gps.latitude, gps.longitude).then((result) => { 57 58 if (result) { 58 59 const name = formatLocationName(result)
+8
app/lib/preferences.ts
··· 32 32 33 33 export const pinnedFeeds = writable<PinnedFeed[]>(DEFAULT_PINNED); 34 34 export const includeExif = writable(true); 35 + export const includeLocation = writable(true); 35 36 36 37 function isValidFeed(f: unknown): f is PinnedFeed { 37 38 return ( ··· 50 51 if (valid.length > 0) pinnedFeeds.set(valid); 51 52 } 52 53 if (typeof prefs.includeExif === "boolean") includeExif.set(prefs.includeExif); 54 + if (typeof prefs.includeLocation === "boolean") includeLocation.set(prefs.includeLocation); 53 55 } 54 56 55 57 export async function setIncludeExif(value: boolean): Promise<void> { 56 58 includeExif.set(value); 57 59 await callXrpc("dev.hatk.putPreference", { key: "includeExif", value }); 60 + } 61 + 62 + export async function setIncludeLocation(value: boolean): Promise<void> { 63 + includeLocation.set(value); 64 + await callXrpc("dev.hatk.putPreference", { key: "includeLocation", value }); 58 65 } 59 66 60 67 export async function pinFeed(feed: PinnedFeed): Promise<boolean> { ··· 86 93 export function resetPreferences(): void { 87 94 pinnedFeeds.set(DEFAULT_PINNED); 88 95 includeExif.set(true); 96 + includeLocation.set(true); 89 97 } 90 98 91 99 export async function markNotificationsSeen(): Promise<void> {
+2 -2
app/routes/create/+page.svelte
··· 13 13 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 14 14 import Button from '$lib/components/atoms/Button.svelte' 15 15 import Field from '$lib/components/atoms/Field.svelte' 16 - import { includeExif } from '$lib/preferences' 16 + import { includeExif, includeLocation } from '$lib/preferences' 17 17 import { viewer } from '$lib/stores' 18 18 import Input from '$lib/components/atoms/Input.svelte' 19 19 import Textarea from '$lib/components/atoms/Textarea.svelte' ··· 64 64 65 65 // Auto-suggest location from first photo's GPS 66 66 const gps = photos.find((p) => p.gps)?.gps 67 - if (gps) { 67 + if (gps && $includeLocation) { 68 68 reverseGeocode(gps.latitude, gps.longitude).then((result) => { 69 69 if (result) { 70 70 const name = formatLocationName(result)
+25 -3
app/routes/settings/profile/+page.svelte
··· 13 13 import RichTextarea from '$lib/components/atoms/RichTextarea.svelte' 14 14 import Toast from '$lib/components/atoms/Toast.svelte' 15 15 import Checkbox from '$lib/components/atoms/Checkbox.svelte' 16 - import { setIncludeExif } from '$lib/preferences' 16 + import { setIncludeExif, setIncludeLocation } from '$lib/preferences' 17 17 import { Camera, LoaderCircle, Trash2 } from 'lucide-svelte' 18 18 19 19 let displayName = $state('') ··· 28 28 let showToast = $state(false) 29 29 let loaded = $state(false) 30 30 let localIncludeExif = $state(true) 31 + let localIncludeLocation = $state(true) 31 32 let fileInput: HTMLInputElement = $state()! 32 33 33 34 const profile = createQuery(() => actorProfileQuery($viewer?.did ?? '')) ··· 42 43 avatarPreview = p.avatar || null 43 44 if (typeof prefs.data?.includeExif === 'boolean') { 44 45 localIncludeExif = prefs.data.includeExif as boolean 46 + } 47 + if (typeof prefs.data?.includeLocation === 'boolean') { 48 + localIncludeLocation = prefs.data.includeLocation as boolean 45 49 } 46 50 loaded = true 47 51 }) ··· 120 124 record, 121 125 }) 122 126 123 - // Save EXIF preference 127 + // Save preferences 124 128 await setIncludeExif(localIncludeExif) 129 + await setIncludeLocation(localIncludeLocation) 125 130 queryClient.invalidateQueries({ queryKey: ['preferences'] }) 126 131 queryClient.invalidateQueries({ queryKey: ['actorProfile', $viewer.did] }) 127 132 ··· 180 185 </div> 181 186 182 187 <div class="preferences"> 183 - <Checkbox bind:checked={localIncludeExif} label="Include camera data (EXIF) when uploading photos" /> 188 + <h3 class="preferences-header">Privacy</h3> 189 + <Checkbox bind:checked={localIncludeLocation} label="Include location" /> 190 + <Checkbox bind:checked={localIncludeExif} label="Include camera data" /> 191 + <p class="preferences-note">Camera data includes make, model, and exposure info. Location is auto-detected from photo metadata when available.</p> 184 192 </div> 185 193 186 194 <div class="actions"> ··· 253 261 margin-top: 24px; 254 262 padding-top: 16px; 255 263 border-top: 1px solid var(--border); 264 + } 265 + .preferences-header { 266 + margin: 0 0 8px; 267 + font-size: 13px; 268 + font-weight: 600; 269 + color: var(--text-muted); 270 + text-transform: uppercase; 271 + letter-spacing: 0.04em; 272 + } 273 + .preferences-note { 274 + margin: 8px 0 0; 275 + font-size: 12px; 276 + color: var(--text-muted); 277 + line-height: 1.4; 256 278 } 257 279 .actions { 258 280 margin-top: 24px;