tangled
alpha
login
or
join now
margin.at
/
margin
87
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
87
fork
atom
overview
issues
4
pulls
1
pipelines
lint
scanash.com
1 month ago
4a171bb3
92ad1ff3
+96
-102
6 changed files
expand all
collapse all
unified
split
web
src
api
client.ts
components
common
Card.tsx
modals
EditItemModal.tsx
types.ts
views
core
AdminModeration.tsx
profile
Profile.tsx
+3
-2
web/src/api/client.ts
···
7
7
NotificationItem,
8
8
Target,
9
9
Selector,
10
10
+
HydratedLabel,
10
11
} from "../types";
11
12
export type { Collection } from "../types";
12
13
···
1130
1131
if (!res.ok) return false;
1131
1132
const data = await res.json();
1132
1133
return data.isAdmin || false;
1133
1133
-
} catch (e) {
1134
1134
+
} catch {
1134
1135
return false;
1135
1136
}
1136
1137
}
···
1209
1210
export async function adminGetLabels(
1210
1211
limit = 50,
1211
1212
offset = 0,
1212
1212
-
): Promise<{ items: any[] }> {
1213
1213
+
): Promise<{ items: HydratedLabel[] }> {
1213
1214
try {
1214
1215
const res = await apiRequest(
1215
1216
`/api/moderation/admin/labels?limit=${limit}&offset=${offset}`,
+41
-42
web/src/components/common/Card.tsx
···
121
121
const [showReportModal, setShowReportModal] = useState(false);
122
122
const [showEditModal, setShowEditModal] = useState(false);
123
123
const [contentRevealed, setContentRevealed] = useState(false);
124
124
+
const [ogData, setOgData] = useState<{
125
125
+
title?: string;
126
126
+
description?: string;
127
127
+
image?: string;
128
128
+
icon?: string;
129
129
+
} | null>(null);
130
130
+
const [imgError, setImgError] = useState(false);
131
131
+
const [iconError, setIconError] = useState(false);
124
132
125
133
const contentWarning = getContentWarning(item.labels, preferences);
126
126
-
127
127
-
if (contentWarning?.visibility === "hide") return null;
128
134
129
135
React.useEffect(() => {
130
136
setItem(initialItem);
···
145
151
const isSemble =
146
152
item.uri?.includes("network.cosmik") || item.uri?.includes("semble");
147
153
154
154
+
const safeUrlHostname = (url: string | null | undefined) => {
155
155
+
if (!url) return null;
156
156
+
try {
157
157
+
return new URL(url).hostname;
158
158
+
} catch {
159
159
+
return null;
160
160
+
}
161
161
+
};
162
162
+
163
163
+
const pageUrl = item.target?.source || item.source;
164
164
+
const isBookmark = type === "bookmark";
165
165
+
166
166
+
React.useEffect(() => {
167
167
+
if (isBookmark && item.uri && !ogData && pageUrl) {
168
168
+
const fetchMetadata = async () => {
169
169
+
try {
170
170
+
const res = await fetch(
171
171
+
`/api/url-metadata?url=${encodeURIComponent(pageUrl)}`,
172
172
+
);
173
173
+
if (res.ok) {
174
174
+
const data = await res.json();
175
175
+
setOgData(data);
176
176
+
}
177
177
+
} catch (e) {
178
178
+
console.error("Failed to fetch metadata", e);
179
179
+
}
180
180
+
};
181
181
+
fetchMetadata();
182
182
+
}
183
183
+
}, [isBookmark, item.uri, pageUrl, ogData]);
184
184
+
185
185
+
if (contentWarning?.visibility === "hide") return null;
186
186
+
148
187
const handleLike = async () => {
149
188
const prev = { liked, likes };
150
189
setLiked(!liked);
···
213
252
214
253
const detailUrl = `/${item.author?.handle || item.author?.did}/${type}/${(item.uri || "").split("/").pop()}`;
215
254
216
216
-
const safeUrlHostname = (url: string | null | undefined) => {
217
217
-
if (!url) return null;
218
218
-
try {
219
219
-
return new URL(url).hostname;
220
220
-
} catch {
221
221
-
return null;
222
222
-
}
223
223
-
};
224
224
-
225
225
-
const pageUrl = item.target?.source || item.source;
226
255
const pageTitle =
227
256
item.target?.title ||
228
257
item.title ||
···
236
265
return clean.length > 60 ? clean.slice(0, 57) + "..." : clean;
237
266
})()
238
267
: null;
239
239
-
const isBookmark = type === "bookmark";
240
240
-
241
241
-
const [ogData, setOgData] = useState<{
242
242
-
title?: string;
243
243
-
description?: string;
244
244
-
image?: string;
245
245
-
icon?: string;
246
246
-
} | null>(null);
247
247
-
248
248
-
const [imgError, setImgError] = useState(false);
249
249
-
const [iconError, setIconError] = useState(false);
250
250
-
251
251
-
React.useEffect(() => {
252
252
-
if (isBookmark && item.uri && !ogData && pageUrl) {
253
253
-
const fetchMetadata = async () => {
254
254
-
try {
255
255
-
const res = await fetch(
256
256
-
`/api/url-metadata?url=${encodeURIComponent(pageUrl)}`,
257
257
-
);
258
258
-
if (res.ok) {
259
259
-
const data = await res.json();
260
260
-
setOgData(data);
261
261
-
}
262
262
-
} catch (e) {
263
263
-
console.error("Failed to fetch metadata", e);
264
264
-
}
265
265
-
};
266
266
-
fetchMetadata();
267
267
-
}
268
268
-
}, [isBookmark, item.uri, pageUrl, ogData]);
269
268
270
269
const displayTitle =
271
270
item.title || ogData?.title || pageTitle || "Untitled Bookmark";
+19
-23
web/src/components/modals/EditItemModal.tsx
···
1
1
-
import React, { useState, useEffect } from "react";
1
1
+
import React, { useState } from "react";
2
2
import { X, ShieldAlert } from "lucide-react";
3
3
import {
4
4
updateAnnotation,
···
38
38
type,
39
39
onSaved,
40
40
}: EditItemModalProps) {
41
41
+
if (!isOpen) return null;
42
42
+
return (
43
43
+
<EditItemModalContent
44
44
+
key={item.uri || item.id || JSON.stringify(item)}
45
45
+
item={item}
46
46
+
type={type}
47
47
+
onClose={onClose}
48
48
+
onSaved={onSaved}
49
49
+
/>
50
50
+
);
51
51
+
}
52
52
+
53
53
+
function EditItemModalContent({
54
54
+
item,
55
55
+
type,
56
56
+
onClose,
57
57
+
onSaved,
58
58
+
}: Omit<EditItemModalProps, "isOpen">) {
41
59
const [text, setText] = useState(item.body?.value || "");
42
60
const [tags, setTags] = useState<string[]>(item.tags || []);
43
61
const [tagInput, setTagInput] = useState("");
44
44
-
45
62
const [color, setColor] = useState(item.color || "yellow");
46
46
-
47
63
const [title, setTitle] = useState(item.title || item.target?.title || "");
48
64
const [description, setDescription] = useState(item.description || "");
49
49
-
50
65
const existingLabels = (item.labels || [])
51
66
.filter((l) => l.src === item.author?.did)
52
67
.map((l) => l.val as ContentLabelValue);
···
55
70
const [showLabelPicker, setShowLabelPicker] = useState(
56
71
existingLabels.length > 0,
57
72
);
58
58
-
59
73
const [saving, setSaving] = useState(false);
60
74
const [error, setError] = useState<string | null>(null);
61
61
-
62
62
-
useEffect(() => {
63
63
-
if (isOpen) {
64
64
-
setText(item.body?.value || "");
65
65
-
setTags(item.tags || []);
66
66
-
setTagInput("");
67
67
-
setColor(item.color || "yellow");
68
68
-
setTitle(item.title || item.target?.title || "");
69
69
-
setDescription(item.description || "");
70
70
-
const labels = (item.labels || [])
71
71
-
.filter((l) => l.src === item.author?.did)
72
72
-
.map((l) => l.val as ContentLabelValue);
73
73
-
setSelfLabels(labels);
74
74
-
setShowLabelPicker(labels.length > 0);
75
75
-
}
76
76
-
}, [isOpen, item]);
77
77
-
78
78
-
if (!isOpen) return null;
79
75
80
76
const addTag = () => {
81
77
const t = tagInput.trim().toLowerCase();
+20
web/src/types.ts
···
214
214
name: string;
215
215
labels: LabelDefinition[];
216
216
}
217
217
+
218
218
+
export interface HydratedLabel {
219
219
+
id: number;
220
220
+
src: string;
221
221
+
uri: string;
222
222
+
val: string;
223
223
+
createdBy: {
224
224
+
did: string;
225
225
+
handle: string;
226
226
+
displayName?: string;
227
227
+
avatar?: string;
228
228
+
};
229
229
+
createdAt: string;
230
230
+
subject?: {
231
231
+
did: string;
232
232
+
handle: string;
233
233
+
displayName?: string;
234
234
+
avatar?: string;
235
235
+
};
236
236
+
}
+11
-31
web/src/views/core/AdminModeration.tsx
···
9
9
adminDeleteLabel,
10
10
adminGetLabels,
11
11
} from "../../api/client";
12
12
-
import type { ModerationReport } from "../../types";
12
12
+
import type { ModerationReport, HydratedLabel } from "../../types";
13
13
import {
14
14
Shield,
15
15
CheckCircle,
···
57
57
{ val: "misleading", label: "Misleading" },
58
58
];
59
59
60
60
-
interface HydratedLabel {
61
61
-
id: number;
62
62
-
src: string;
63
63
-
uri: string;
64
64
-
val: string;
65
65
-
createdBy: {
66
66
-
did: string;
67
67
-
handle: string;
68
68
-
displayName?: string;
69
69
-
avatar?: string;
70
70
-
};
71
71
-
createdAt: string;
72
72
-
subject?: {
73
73
-
did: string;
74
74
-
handle: string;
75
75
-
displayName?: string;
76
76
-
avatar?: string;
77
77
-
};
78
78
-
}
79
79
-
80
60
type Tab = "reports" | "labels" | "actions";
81
61
82
62
export default function AdminModeration() {
···
100
80
const [labelSubmitting, setLabelSubmitting] = useState(false);
101
81
const [labelSuccess, setLabelSuccess] = useState(false);
102
82
103
103
-
useEffect(() => {
104
104
-
const init = async () => {
105
105
-
const admin = await checkAdminAccess();
106
106
-
setIsAdmin(admin);
107
107
-
if (admin) await loadReports("pending");
108
108
-
setLoading(false);
109
109
-
};
110
110
-
init();
111
111
-
}, []);
112
112
-
113
83
const loadReports = async (status: string) => {
114
84
const data = await getAdminReports(status || undefined);
115
85
setReports(data.items);
···
121
91
const data = await adminGetLabels();
122
92
setLabels(data.items || []);
123
93
};
94
94
+
95
95
+
useEffect(() => {
96
96
+
const init = async () => {
97
97
+
const admin = await checkAdminAccess();
98
98
+
setIsAdmin(admin);
99
99
+
if (admin) await loadReports("pending");
100
100
+
setLoading(false);
101
101
+
};
102
102
+
init();
103
103
+
}, []);
124
104
125
105
const handleTabChange = async (tab: Tab) => {
126
106
setActiveTab(tab);
+2
-4
web/src/views/profile/Profile.tsx
···
7
7
unblockUser,
8
8
muteUser,
9
9
unmuteUser,
10
10
+
getModerationRelationship,
10
11
} from "../../api/client";
11
12
import Card from "../../components/common/Card";
12
13
import RichText from "../../components/common/RichText";
···
38
39
Collection,
39
40
ModerationRelationship,
40
41
ContentLabel,
41
41
-
LabelVisibility,
42
42
} from "../../types";
43
43
import { useStore } from "@nanostores/react";
44
44
import { $user } from "../../store/auth";
···
159
159
160
160
if (user && user.did !== did) {
161
161
try {
162
162
-
const { getModerationRelationship } =
163
163
-
await import("../../api/client");
164
162
const rel = await getModerationRelationship(did);
165
163
setModRelation(rel);
166
164
} catch {
···
174
172
}
175
173
};
176
174
if (did) loadProfile();
177
177
-
}, [did]);
175
175
+
}, [did, user]);
178
176
179
177
useEffect(() => {
180
178
loadPreferences();